From b2a287346a8f539049abe02105b6e9691fa97d6e Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 17 Sep 2024 10:58:17 +0100 Subject: [PATCH 01/35] add fleet integration --- .../home/hooks/use_available_packages.tsx | 15 ++ .../cards/integrations/hooks.ts | 75 ++++++ .../cards/integrations/integrations_card.tsx | 228 ++++++++++++++++-- .../cards/integrations/utils.ts | 25 ++ 4 files changed, 326 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx index c7b1f936e2424..d2e162fce26bf 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx @@ -35,6 +35,8 @@ import type { CategoryFacet } from '../category_facets'; import { mergeCategoriesAndCount } from '../util'; +import { installationStatuses } from '../../../../../../../../common/constants'; + import { useBuildIntegrationsUrl } from './use_build_integrations_url'; export interface IntegrationsURLParameters { @@ -146,6 +148,18 @@ export const useAvailablePackages = ({ }); } + const installedIntegrationList = useMemo( + () => + packageListToIntegrationsList( + (eprPackages?.items || []).filter( + (pkg) => + pkg.status === installationStatuses.Installed || + pkg.status === installationStatuses.InstallFailed + ) + ), + [eprPackages] + ); + const eprIntegrationList = useMemo( () => packageListToIntegrationsList(eprPackages?.items || []), [eprPackages] @@ -238,6 +252,7 @@ export const useAvailablePackages = ({ setUrlandReplaceHistory, preference, setPreference, + installedIntegrationList, isLoading: isLoadingReplacmentCustomIntegrations || isLoadingAppendCustomIntegrations || diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts new file mode 100644 index 0000000000000..9476dcf5d97ad --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts @@ -0,0 +1,75 @@ +/* + * 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 { useMemo } from 'react'; +import type { IntegrationCardItem } from '@kbn/fleet-plugin/public'; + +export type VirtualCard = { + type: 'virtual'; +} & IntegrationCardItem; + +export interface FeaturedCard { + type: 'featured'; + name: string; +} + +export type CustomCard = FeaturedCard | VirtualCard; + +function extractFeaturedCards(filteredCards: IntegrationCardItem[], featuredCardNames?: string[]) { + const featuredCards: Record = {}; + filteredCards.forEach((card) => { + if (featuredCardNames?.includes(card.name)) { + featuredCards[card.name] = card; + } + }); + return featuredCards; +} + +function formatCustomCards( + customCards: string[], + featuredCards: Record +) { + return customCards.reduce((acc: IntegrationCardItem[], cardName) => { + if (featuredCards[cardName] != null) { + acc.push(featuredCards[cardName]); + } + return acc; + }, []); +} + +function useFilteredCards(integrationsList: IntegrationCardItem[], customCards?: string[]) { + return useMemo(() => { + if (!customCards) { + return { featuredCards: {}, integrationCards: integrationsList }; + } + + return { + featuredCards: extractFeaturedCards(integrationsList, customCards), + integrationCards: integrationsList, + }; + }, [integrationsList, customCards]); +} + +/** + * Formats the cards to display on the integration list. + * @param integrationsList the list of cards from the integrations API. + * @param selectedCategory the card category to filter by. + * @param customCards any virtual or featured cards. + * @param fullList when true all integration cards are included. + * @returns the list of cards to display. + */ +export function useIntegrationCardList( + integrationsList: IntegrationCardItem[], + customCards?: string[] +): IntegrationCardItem[] { + const { featuredCards, integrationCards } = useFilteredCards(integrationsList, customCards); + + if (customCards && customCards.length > 0) { + return formatCustomCards(customCards, featuredCards); + } + return integrationCards ?? []; +} diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index 4a95a38c7c571..25b604d6383ab 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -5,11 +5,91 @@ * 2.0. */ -import React from 'react'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import useAsyncRetry from 'react-use/lib/useAsyncRetry'; + +import { + EuiButton, + EuiButtonGroup, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiSkeletonText, +} from '@elastic/eui'; +import type { + AvailablePackagesHookType, + IntegrationCardItem, + CategoryFacet, +} from '@kbn/fleet-plugin/public'; +import { noop } from 'lodash'; + +import { FormattedMessage } from '@kbn/i18n-react'; import type { OnboardingCardComponent } from '../../../../types'; import { OnboardingCardContentPanel } from '../common/card_content_panel'; -import { CardCallOut } from '../common/card_callout'; +import { PackageList, fetchAvailablePackagesHook } from './utils'; +import { useIntegrationCardList } from './hooks'; + +interface Props { + /** + * A subset of either existing card names to feature, or virtual + * cards to display. The inclusion of CustomCards will override the default + * list functionality. + */ + onLoaded?: () => void; +} + +type WrapperProps = Props & { + useAvailablePackages: AvailablePackagesHookType; +}; + +const Loading = () => ; + +const categories: CategoryFacet[] = []; +const tabs = [ + { + id: 'featured', + label: 'Recommended', + category: '', + customCards: ['1password'], + iconType: 'starFilled', + }, + { + id: 'network', + label: 'Network', + category: 'security', + subCategory: 'network_security', + }, + { + id: 'user', + label: 'User', + category: 'security', + subCategory: 'iam', + }, + { + id: 'endpoint', + label: 'Endpoint', + category: 'security', + subCategory: 'edr_xdr', + }, + { + id: 'cloud', + label: 'Cloud', + category: 'security', + subCategory: 'cloudsecurity_cdr', + }, + { + id: 'threatIntel', + label: 'Threat Intel', + category: 'security', + subCategory: 'threat_intel', + }, + { + id: 'all', + label: 'All', + category: '', + }, +]; +const defaultTab = tabs[0]; export const IntegrationsCard: OnboardingCardComponent = ({ setComplete, @@ -18,23 +98,137 @@ export const IntegrationsCard: OnboardingCardComponent = ({ // TODO: implement. This is just for demo purposes return ( - - - {checkCompleteMetadata ? ( - - ) : ( - - )} - - - setComplete(false)}>{'Set not complete'} - - + ); }; +const PackageListGridWrapper = ({ useAvailablePackages, onLoaded }: WrapperProps) => { + const [toggleIdSelected, setToggleIdSelected] = useState(defaultTab.id); + const onChange = (optionId: string) => { + setToggleIdSelected(optionId); + }; + + const { + filteredCards, + installedIntegrationList, + isLoading, + searchTerm: searchQuery, + setCategory, + setSearchTerm, + setSelectedSubCategory, + } = useAvailablePackages({ + prereleaseIntegrationsEnabled: false, + }); + + const selectedTab = useMemo( + () => tabs.find(({ id }) => id === toggleIdSelected), + [toggleIdSelected] + ); + + const selectedCategory = selectedTab?.category ?? ''; + const selectedSubCategory = selectedTab?.subCategory; + const customCards = useMemo(() => selectedTab?.customCards, [selectedTab]); + + useEffect(() => { + setCategory(selectedCategory); + setSelectedSubCategory(selectedSubCategory); + }); + + const list: IntegrationCardItem[] = useIntegrationCardList(filteredCards, customCards); + + useEffect(() => { + if (!isLoading) { + onLoaded?.(); + } + }, [isLoading, onLoaded]); + + if (isLoading) return ; + + return ( + + + onChange(id)} + color="primary" + buttonSize="compressed" + /> + + + }> +
+ +
+
+
+
+ ); +}; + +const WithAvailablePackages = React.forwardRef((props: Props) => { + const ref = useRef(null); + + const { + error: errorLoading, + retry: retryAsyncLoad, + loading: asyncLoading, + } = useAsyncRetry(async () => { + ref.current = await fetchAvailablePackagesHook(); + }); + + if (errorLoading) + return ( + +

+ +

+ { + if (!asyncLoading) retryAsyncLoad(); + }} + > + + +
+ ); + + if (asyncLoading || ref.current === null) return ; + + return ; +}); + // eslint-disable-next-line import/no-default-export export default IntegrationsCard; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts new file mode 100644 index 0000000000000..03d7ef18bcb2a --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts @@ -0,0 +1,25 @@ +/* + * 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 { lazy } from 'react'; +import type { AvailablePackagesHookType, UseGetPackagesQuery } from '@kbn/fleet-plugin/public'; + +export const PackageList = lazy(async () => ({ + default: await import('@kbn/fleet-plugin/public') + .then((module) => module.PackageList()) + .then((pkg) => pkg.PackageListGrid), +})); + +export const fetchAvailablePackagesHook = (): Promise => + import('@kbn/fleet-plugin/public') + .then((module) => module.AvailablePackagesHook()) + .then((hook) => hook.useAvailablePackages); + +export const getPackagesQueryHook = (): Promise => + import('@kbn/fleet-plugin/public') + .then((module) => module.GetPackagesQueryHook()) + .then((hook) => hook.useGetPackagesQuery); From b226a3ca99de1036cffb17a33d1295af7e815a31 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 17 Sep 2024 18:17:00 +0100 Subject: [PATCH 02/35] add installation status --- .../plugins/fleet/common/constants/routes.ts | 2 +- .../sections/epm/components/package_card.tsx | 31 ++++- .../screens/detail/components/back_link.tsx | 3 +- .../sections/epm/screens/home/card_utils.tsx | 6 + .../home/hooks/use_available_packages.tsx | 13 -- .../plugins/fleet/public/constants/index.ts | 1 + x-pack/plugins/fleet/public/index.ts | 4 +- .../security_solution/common/constants.ts | 5 +- .../cards/integrations/hooks.ts | 119 +++++++++++------- .../cards/integrations/integrations_card.tsx | 7 +- .../integrations_check_complete.ts | 31 ++--- .../cards/integrations/utils.ts | 7 +- .../onboarding_body/onboarding_body.tsx | 14 ++- .../public/onboarding/types.ts | 4 +- 14 files changed, 156 insertions(+), 91 deletions(-) diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index 9b5c35c3b3ce2..e096a33d830f0 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -20,7 +20,7 @@ export const DOWNLOAD_SOURCE_API_ROOT = `${API_ROOT}/agent_download_sources`; export const LIMITED_CONCURRENCY_ROUTE_TAG = 'ingest:limited-concurrency'; // EPM API routes -const EPM_PACKAGES_MANY = `${EPM_API_ROOT}/packages`; +export const EPM_PACKAGES_MANY = `${EPM_API_ROOT}/packages`; const EPM_PACKAGES_INSTALLED = `${EPM_API_ROOT}/packages/installed`; const EPM_PACKAGES_BULK = `${EPM_PACKAGES_MANY}/_bulk`; const EPM_PACKAGES_ONE_DEPRECATED = `${EPM_PACKAGES_MANY}/{pkgkey}`; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index 474ffe2e4db70..ce0301e6cefbe 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -5,14 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import { EuiBadge, EuiButton, + EuiCallOut, EuiCard, EuiFlexGroup, EuiFlexItem, + EuiIcon, EuiSpacer, EuiToolTip, } from '@elastic/eui'; @@ -39,7 +41,7 @@ export type PackageCardProps = IntegrationCardItem; // Min-height is roughly 3 lines of content. // This keeps the cards from looking overly unbalanced because of content differences. -const Card = styled(EuiCard)<{ isquickstart?: boolean }>` +const Card = styled(EuiCard)<{ isquickstart?: boolean; isQuickstart?: boolean }>` min-height: 127px; border-color: ${({ isquickstart }) => (isquickstart ? '#ba3d76' : null)}; `; @@ -59,8 +61,10 @@ export function PackageCard({ isUnverified, isUpdateAvailable, showLabels = true, + showInstallationStatus, extraLabelsBadges, isQuickstart = false, + isInstalled, onCardClick: onClickProp = undefined, isCollectionCard = false, }: PackageCardProps) { @@ -150,6 +154,28 @@ export function PackageCard({ ); } + const installStatus = useMemo( + () => + showInstallationStatus && isInstalled ? ( + + } + color="success" + /> + ) : undefined, + [showInstallationStatus, isInstalled] + ); + const { application } = useStartServices(); const isGuidedOnboardingActive = useIsGuidedOnboardingActive(name); @@ -214,6 +240,7 @@ export function PackageCard({ {releaseBadge} {hasDeferredInstallationsBadge} {collectionButton} + {installStatus} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx index 081b78de8ec51..76aa46fd3a56c 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx @@ -17,7 +17,8 @@ interface Props { export function BackLink({ queryParams, href: integrationsHref }: Props) { const { onboardingLink } = useMemo(() => { return { - onboardingLink: queryParams.get('observabilityOnboardingLink'), + onboardingLink: + queryParams.get('observabilityOnboardingLink') || queryParams.get('onboardingLink'), }; }, [queryParams]); const href = onboardingLink ?? integrationsHref; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx index f8c579df39f3b..12f0c84725cc6 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx @@ -57,6 +57,8 @@ export interface IntegrationCardItem { extraLabelsBadges?: React.ReactNode[]; onCardClick?: () => void; isCollectionCard?: boolean; + showInstallationStatus?: boolean; + isInstalled: boolean; } export const mapToCard = ({ @@ -126,6 +128,10 @@ export const mapToCard = ({ isUnverified, isUpdateAvailable, extraLabelsBadges, + isInstalled: + item.type === 'integration' && + (item?.installationInfo?.install_status === installationStatuses.Installed || + item?.installationInfo?.install_status === installationStatuses.InstallFailed), }; }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx index d2e162fce26bf..0638eecd7254d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx @@ -148,18 +148,6 @@ export const useAvailablePackages = ({ }); } - const installedIntegrationList = useMemo( - () => - packageListToIntegrationsList( - (eprPackages?.items || []).filter( - (pkg) => - pkg.status === installationStatuses.Installed || - pkg.status === installationStatuses.InstallFailed - ) - ), - [eprPackages] - ); - const eprIntegrationList = useMemo( () => packageListToIntegrationsList(eprPackages?.items || []), [eprPackages] @@ -252,7 +240,6 @@ export const useAvailablePackages = ({ setUrlandReplaceHistory, preference, setPreference, - installedIntegrationList, isLoading: isLoadingReplacmentCustomIntegrations || isLoadingAppendCustomIntegrations || diff --git a/x-pack/plugins/fleet/public/constants/index.ts b/x-pack/plugins/fleet/public/constants/index.ts index 4fbe799aa7337..56bd3a9e7a9f7 100644 --- a/x-pack/plugins/fleet/public/constants/index.ts +++ b/x-pack/plugins/fleet/public/constants/index.ts @@ -33,6 +33,7 @@ export { AGENT_POLICY_MAPPINGS, AGENT_MAPPINGS, ENROLLMENT_API_KEY_MAPPINGS, + EPM_PACKAGES_MANY, } from '../../common/constants'; export * from './page_paths'; diff --git a/x-pack/plugins/fleet/public/index.ts b/x-pack/plugins/fleet/public/index.ts index 7661cbc64ad31..c0131c1fba17a 100644 --- a/x-pack/plugins/fleet/public/index.ts +++ b/x-pack/plugins/fleet/public/index.ts @@ -10,6 +10,8 @@ import type { PluginInitializerContext } from '@kbn/core/public'; import { lazy } from 'react'; import { FleetPlugin } from './plugin'; +export type { GetPackagesResponse } from './types'; +export { installationStatuses } from '../common/constants'; export type { FleetSetup, FleetStart, FleetStartServices } from './plugin'; @@ -54,7 +56,7 @@ export type { UIExtensionsStorage, } from './types/ui_extensions'; -export { pagePathGetters } from './constants'; +export { pagePathGetters, EPM_PACKAGES_MANY } from './constants'; export { pkgKeyFromPackageInfo } from './services'; export type { CustomAssetsAccordionProps } from './components/custom_assets_accordion'; export { CustomAssetsAccordion } from './components/custom_assets_accordion'; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 23ec7c128d668..61fe94ae9b5de 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -27,8 +27,9 @@ export const APP_NAME = 'Security' as const; export const APP_ICON = 'securityAnalyticsApp' as const; export const APP_ICON_SOLUTION = 'logoSecurity' as const; export const APP_PATH = `/app/security` as const; -export const ADD_DATA_PATH = `/app/integrations/browse/security`; -export const ADD_THREAT_INTELLIGENCE_DATA_PATH = `/app/integrations/browse/threat_intel`; +export const APP_INTEGRATIONS_PATH = `/app/integrations` as const; +export const ADD_DATA_PATH = `${APP_INTEGRATIONS_PATH}/browse/security`; +export const ADD_THREAT_INTELLIGENCE_DATA_PATH = `${APP_INTEGRATIONS_PATH}/browse/threat_intel`; export const DEFAULT_BYTES_FORMAT = 'format:bytes:defaultPattern' as const; export const DEFAULT_DATE_FORMAT = 'dateFormat' as const; export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz' as const; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts index 9476dcf5d97ad..7253c4d577f9f 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts @@ -7,69 +7,98 @@ import { useMemo } from 'react'; import type { IntegrationCardItem } from '@kbn/fleet-plugin/public'; +import { isEmpty } from 'lodash'; +import { + APP_INTEGRATIONS_PATH, + APP_PATH, + ONBOARDING_PATH, +} from '../../../../../../common/constants'; +import { useKibana } from '../../../../../common/lib/kibana'; -export type VirtualCard = { - type: 'virtual'; -} & IntegrationCardItem; - -export interface FeaturedCard { +export interface CustomCard { type: 'featured'; name: string; } -export type CustomCard = FeaturedCard | VirtualCard; - function extractFeaturedCards(filteredCards: IntegrationCardItem[], featuredCardNames?: string[]) { - const featuredCards: Record = {}; - filteredCards.forEach((card) => { + return filteredCards.reduce((acc: Record, card) => { if (featuredCardNames?.includes(card.name)) { - featuredCards[card.name] = card; + acc[card.name] = card; } - }); - return featuredCards; + return acc; + }, {}); } -function formatCustomCards( - customCards: string[], - featuredCards: Record +function getFilteredCards( + integrationsList: IntegrationCardItem[], + customCards?: string[], + basePath?: string, + installedIntegrationList?: IntegrationCardItem[] ) { - return customCards.reduce((acc: IntegrationCardItem[], cardName) => { - if (featuredCards[cardName] != null) { - acc.push(featuredCards[cardName]); - } - return acc; - }, []); + const securityIntegrationsList = integrationsList.map((card) => + addSecuritySpecificProps({ card, basePath, installedIntegrationList }) + ); + if (!customCards) { + return { featuredCards: {}, integrationCards: securityIntegrationsList }; + } + + return { + featuredCards: extractFeaturedCards(securityIntegrationsList, customCards), + integrationCards: securityIntegrationsList, + }; } -function useFilteredCards(integrationsList: IntegrationCardItem[], customCards?: string[]) { - return useMemo(() => { - if (!customCards) { - return { featuredCards: {}, integrationCards: integrationsList }; - } +function addPathParamToUrl(url: string, onboardingLink: string) { + const encoded = encodeURIComponent(onboardingLink); + if (url.indexOf('?') >= 0) { + return `${url}&onboardingLink=${encoded}`; + } + return `${url}?onboardingLink=${encoded}`; +} + +function getOnboardingPath(basePath?: string): string | null { + const onboardingPath = `${APP_PATH}${ONBOARDING_PATH}`; + const path = !isEmpty(basePath) ? `${basePath}/${onboardingPath}` : onboardingPath; - return { - featuredCards: extractFeaturedCards(integrationsList, customCards), - integrationCards: integrationsList, - }; - }, [integrationsList, customCards]); + return path; } -/** - * Formats the cards to display on the integration list. - * @param integrationsList the list of cards from the integrations API. - * @param selectedCategory the card category to filter by. - * @param customCards any virtual or featured cards. - * @param fullList when true all integration cards are included. - * @returns the list of cards to display. - */ -export function useIntegrationCardList( - integrationsList: IntegrationCardItem[], - customCards?: string[] -): IntegrationCardItem[] { - const { featuredCards, integrationCards } = useFilteredCards(integrationsList, customCards); +function addSecuritySpecificProps({ + basePath, + card, + installedIntegrationList, +}: { + basePath?: string; + card: IntegrationCardItem; + installedIntegrationList?: IntegrationCardItem[]; +}): IntegrationCardItem { + const onboardingLink = getOnboardingPath(basePath); + return { + ...card, + showInstallationStatus: true, + url: + card.url.indexOf(APP_INTEGRATIONS_PATH) >= 0 && onboardingLink + ? addPathParamToUrl(card.url, onboardingLink) + : card.url, + }; +} + +export function useIntegrationCardList({ + integrationsList, + customCards, +}: { + integrationsList: IntegrationCardItem[]; + customCards?: string[]; +}): IntegrationCardItem[] { + const kibana = useKibana(); + const basePath = kibana.services.http?.basePath.get(); + const { featuredCards, integrationCards } = useMemo( + () => getFilteredCards(integrationsList, customCards, basePath), + [integrationsList, customCards, basePath] + ); if (customCards && customCards.length > 0) { - return formatCustomCards(customCards, featuredCards); + return Object.values(featuredCards) ?? []; } return integrationCards ?? []; } diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index 25b604d6383ab..5a8e8bd00d716 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -24,6 +24,7 @@ import type { import { noop } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; import type { OnboardingCardComponent } from '../../../../types'; import { OnboardingCardContentPanel } from '../common/card_content_panel'; import { PackageList, fetchAvailablePackagesHook } from './utils'; @@ -135,7 +136,11 @@ const PackageListGridWrapper = ({ useAvailablePackages, onLoaded }: WrapperProps setSelectedSubCategory(selectedSubCategory); }); - const list: IntegrationCardItem[] = useIntegrationCardList(filteredCards, customCards); + const list: IntegrationCardItem[] = useIntegrationCardList({ + integrationsList: filteredCards, + customCards, + installedIntegrationList, + }); useEffect(() => { if (!isLoading) { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts index 872b077222062..ca58eb5cc67fa 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts @@ -5,23 +5,24 @@ * 2.0. */ +import type { GetPackagesResponse } from '@kbn/fleet-plugin/public'; +import { EPM_PACKAGES_MANY, installationStatuses } from '@kbn/fleet-plugin/public'; +import type { HttpSetup } from '@kbn/core/public'; import type { OnboardingCardCheckComplete } from '../../../../types'; import { getDummyAdditionalBadge } from './integrations_header_badges'; -export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async () => { - // implement this function - return new Promise((resolve) => - setTimeout( - () => - resolve({ - isComplete: true, - completeBadgeText: '3 integrations installed', - additionalBadges: [getDummyAdditionalBadge()], - metadata: { - integrationsInstalled: 3, - }, - }), - 2000 - ) +export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async ({ + http, +}: { + http: HttpSetup; +}) => { + const data = await http.get(EPM_PACKAGES_MANY, { + version: '2023-10-31', + }); + const installed = (data?.items || []).filter( + (pkg) => + pkg.status === installationStatuses.Installed || + pkg.status === installationStatuses.InstallFailed ); + return installed.length > 0; }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts index 03d7ef18bcb2a..7abcb19c06b63 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts @@ -6,7 +6,7 @@ */ import { lazy } from 'react'; -import type { AvailablePackagesHookType, UseGetPackagesQuery } from '@kbn/fleet-plugin/public'; +import type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public'; export const PackageList = lazy(async () => ({ default: await import('@kbn/fleet-plugin/public') @@ -18,8 +18,3 @@ export const fetchAvailablePackagesHook = (): Promise import('@kbn/fleet-plugin/public') .then((module) => module.AvailablePackagesHook()) .then((hook) => hook.useAvailablePackages); - -export const getPackagesQueryHook = (): Promise => - import('@kbn/fleet-plugin/public') - .then((module) => module.GetPackagesQueryHook()) - .then((hook) => hook.useGetPackagesQuery); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx index d363bb702d192..4c316dcff16e7 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx @@ -17,10 +17,18 @@ import { useCompletedCards } from './hooks/use_completed_cards'; export const OnboardingBody = React.memo(() => { const bodyConfig = useBodyConfig(); + const { expandedCardId, setExpandedCardId } = useExpandedCard(spaceId); + const { isCardComplete, setCardComplete } = useCompletedCards(spaceId); - const { expandedCardId, setExpandedCardId } = useExpandedCard(); - const { isCardComplete, setCardComplete, getCardCheckCompleteResult, checkCardComplete } = - useCompletedCards(bodyConfig); + const { checkAllCardsComplete, checkCardComplete } = useCheckCompleteCards( + bodyConfig, + setCardComplete + ); + + useEffect(() => { + // initial auto-check for all cards + checkAllCardsComplete(); + }, [checkAllCardsComplete]); const createOnToggleExpanded = useCallback( (cardId: OnboardingCardId) => () => { diff --git a/x-pack/plugins/security_solution/public/onboarding/types.ts b/x-pack/plugins/security_solution/public/onboarding/types.ts index f568daa30bc0b..b5f0c53ecb27e 100644 --- a/x-pack/plugins/security_solution/public/onboarding/types.ts +++ b/x-pack/plugins/security_solution/public/onboarding/types.ts @@ -8,6 +8,8 @@ import type React from 'react'; import type { IconType } from '@elastic/eui'; import type { LicenseType } from '@kbn/licensing-plugin/public'; +import type { HttpSetup } from '@kbn/core/public'; + import type { OnboardingCardId } from './constants'; import type { RequiredCapabilities } from '../common/lib/capabilities'; @@ -62,7 +64,7 @@ export type OnboardingCardComponent = React.ComponentType<{ checkCompleteMetadata?: Record; }>; -export type OnboardingCardCheckComplete = () => Promise; +export type OnboardingCardCheckComplete = ({ http }: { http: HttpSetup }) => Promise; export interface OnboardingCardConfig { id: OnboardingCardId; From c6b19927e4d1f456a76b7102ace925b9562b8659 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 18 Sep 2024 18:24:04 +0100 Subject: [PATCH 03/35] installation status --- .../sections/epm/components/package_card.tsx | 90 +++++++++++++++---- .../components/package_list_grid/index.tsx | 4 +- .../sections/epm/screens/home/card_utils.tsx | 10 +-- .../home/hooks/use_available_packages.tsx | 2 - .../cards/integrations/hooks.ts | 3 +- .../cards/integrations/integrations_card.tsx | 44 ++++----- 6 files changed, 104 insertions(+), 49 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index ce0301e6cefbe..dd33d2643caa2 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -14,7 +14,6 @@ import { EuiCard, EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiSpacer, EuiToolTip, } from '@elastic/eui'; @@ -36,16 +35,38 @@ import type { IntegrationCardItem } from '../screens/home'; import { InlineReleaseBadge, WithGuidedOnboardingTour } from '../../../components'; import { useStartServices, useIsGuidedOnboardingActive } from '../../../hooks'; import { INTEGRATIONS_BASE_PATH, INTEGRATIONS_PLUGIN_ID } from '../../../constants'; +import type { EpmPackageInstallStatus } from '../../../../../../common/types'; +import { installationStatuses } from '../../../../../../common/constants'; export type PackageCardProps = IntegrationCardItem; // Min-height is roughly 3 lines of content. // This keeps the cards from looking overly unbalanced because of content differences. -const Card = styled(EuiCard)<{ isquickstart?: boolean; isQuickstart?: boolean }>` +const Card = styled(EuiCard)<{ isquickstart?: boolean; fixedCardHeight?: number }>` min-height: 127px; border-color: ${({ isquickstart }) => (isquickstart ? '#ba3d76' : null)}; + ${({ fixedCardHeight }) => + fixedCardHeight ? `max-height: ${fixedCardHeight}px; overflow: hidden;` : ''}; `; +const installStatusMapToColor: Record< + EpmPackageInstallStatus, + { color: string; iconType: string; text: string } +> = { + installed: { + color: 'success', + iconType: 'check', + text: i18n.translate('xpack.fleet.packageCard.installedLabel', { defaultMessage: 'Installed' }), + }, + install_failed: { + color: 'warning', + iconType: 'warning', + text: i18n.translate('xpack.fleet.packageCard.installFailedLabel', { + defaultMessage: 'Install Failed', + }), + }, +}; + export function PackageCard({ description, name, @@ -64,9 +85,11 @@ export function PackageCard({ showInstallationStatus, extraLabelsBadges, isQuickstart = false, - isInstalled, + installStatus, onCardClick: onClickProp = undefined, isCollectionCard = false, + lineClamp, + fixedCardHeight, }: PackageCardProps) { let releaseBadge: React.ReactNode | null = null; @@ -154,26 +177,43 @@ export function PackageCard({ ); } - const installStatus = useMemo( + const installStatusCallout = useMemo( () => - showInstallationStatus && isInstalled ? ( - - } - color="success" - /> + > + + + } + color={installStatusMapToColor[installStatus].color} + /> + ) : undefined, - [showInstallationStatus, isInstalled] + [showInstallationStatus, installStatus] ); const { application } = useStartServices(); @@ -212,6 +252,17 @@ export function PackageCard({ [class*='euiCard__description'] { flex-grow: 1; + ${lineClamp + ? `-webkit-line-clamp: ${ + installStatusCallout ? 1 : lineClamp + };display: -webkit-box;-webkit-box-orient: vertical;overflow: hidden;` + : ''} + } + + [class*='euiCard__titleButton'] { + ${lineClamp + ? `-webkit-line-clamp: ${1};display: -webkit-box;-webkit-box-orient: vertical;overflow: hidden;` + : ''} } `} data-test-subj={testid} @@ -232,6 +283,7 @@ export function PackageCard({ /> } onClick={onClickProp ?? onCardClick} + fixedCardHeight={fixedCardHeight} > {showLabels && extraLabelsBadges ? extraLabelsBadges : null} @@ -240,8 +292,8 @@ export function PackageCard({ {releaseBadge} {hasDeferredInstallationsBadge} {collectionButton} - {installStatus} + {installStatusCallout} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx index 8a6761d48f9b1..b73e7c92e855e 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx @@ -69,6 +69,7 @@ export interface PackageListGridProps { showMissingIntegrationMessage?: boolean; showControls?: boolean; showSearchTools?: boolean; + spacer?: boolean; } export const PackageListGrid: FunctionComponent = ({ @@ -91,6 +92,7 @@ export const PackageListGrid: FunctionComponent = ({ showCardLabels = true, showControls = true, showSearchTools = true, + spacer = true, }) => { const localSearchRef = useLocalSearch(list, !!isLoading); @@ -267,7 +269,7 @@ export const PackageListGrid: FunctionComponent = ({ {callout} ) : null} - + {spacer && } void; isCollectionCard?: boolean; showInstallationStatus?: boolean; - isInstalled: boolean; + installStatus: EpmPackageInstallStatus | null | undefined; + lineClamp?: number; + fixedCardHeight?: number; } export const mapToCard = ({ @@ -128,10 +131,7 @@ export const mapToCard = ({ isUnverified, isUpdateAvailable, extraLabelsBadges, - isInstalled: - item.type === 'integration' && - (item?.installationInfo?.install_status === installationStatuses.Installed || - item?.installationInfo?.install_status === installationStatuses.InstallFailed), + installStatus: item.type === 'integration' ? item.installationInfo?.install_status : null, }; }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx index 0638eecd7254d..c7b1f936e2424 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx @@ -35,8 +35,6 @@ import type { CategoryFacet } from '../category_facets'; import { mergeCategoriesAndCount } from '../util'; -import { installationStatuses } from '../../../../../../../../common/constants'; - import { useBuildIntegrationsUrl } from './use_build_integrations_url'; export interface IntegrationsURLParameters { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts index 7253c4d577f9f..ba89a52a257f3 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts @@ -66,7 +66,6 @@ function getOnboardingPath(basePath?: string): string | null { function addSecuritySpecificProps({ basePath, card, - installedIntegrationList, }: { basePath?: string; card: IntegrationCardItem; @@ -75,6 +74,8 @@ function addSecuritySpecificProps({ const onboardingLink = getOnboardingPath(basePath); return { ...card, + lineClamp: 3, + fixedCardHeight: 127, showInstallationStatus: true, url: card.url.indexOf(APP_INTEGRATIONS_PATH) >= 0 && onboardingLink diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index 5a8e8bd00d716..5687ad6027a88 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -112,7 +112,6 @@ const PackageListGridWrapper = ({ useAvailablePackages, onLoaded }: WrapperProps const { filteredCards, - installedIntegrationList, isLoading, searchTerm: searchQuery, setCategory, @@ -139,7 +138,6 @@ const PackageListGridWrapper = ({ useAvailablePackages, onLoaded }: WrapperProps const list: IntegrationCardItem[] = useIntegrationCardList({ integrationsList: filteredCards, customCards, - installedIntegrationList, }); useEffect(() => { @@ -162,26 +160,30 @@ const PackageListGridWrapper = ({ useAvailablePackages, onLoaded }: WrapperProps buttonSize="compressed" /> - + }> -
- -
+
From 50535678bf89f9fb4543703083d0b794648b2c3f Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Fri, 20 Sep 2024 14:33:09 +0100 Subject: [PATCH 04/35] clean up --- .../epm/components/installation_status.tsx | 84 +++++++ .../sections/epm/components/package_card.tsx | 96 ++------ .../epm/components/package_list_grid/grid.tsx | 6 + .../components/package_list_grid/index.tsx | 3 + .../sections/epm/screens/home/card_utils.tsx | 3 +- x-pack/plugins/fleet/public/index.ts | 1 + .../cards/common/card_content_panel.tsx | 11 +- .../cards/integrations/available_packages.tsx | 68 ++++++ .../cards/integrations/const.ts | 61 +++++ .../cards/integrations/hooks.ts | 114 +++------ .../cards/integrations/integrations_card.tsx | 228 +----------------- .../cards/integrations/package_list_grid.tsx | 144 +++++++++++ .../cards/integrations/types.ts | 15 ++ .../cards/integrations/utils.ts | 78 +++++- .../onboarding/hooks/use_stored_state.ts | 21 ++ 15 files changed, 551 insertions(+), 382 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx new file mode 100644 index 0000000000000..72a2b4af3cc3b --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx @@ -0,0 +1,84 @@ +/* + * 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 from 'react'; + +import { EuiCallOut, EuiSpacer, useEuiTheme } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { installationStatuses } from '../../../../../../common/constants'; +import type { EpmPackageInstallStatus } from '../../../../../../common/types'; + +const installStatusMapToColor: Record< + string, + { color: 'success' | 'warning'; iconType: string; title: string } +> = { + installed: { + color: 'success', + iconType: 'check', + title: i18n.translate('xpack.fleet.packageCard.installedLabel', { + defaultMessage: 'Installed', + }), + }, + install_failed: { + color: 'warning', + iconType: 'warning', + title: i18n.translate('xpack.fleet.packageCard.installFailedLabel', { + defaultMessage: 'Install Failed', + }), + }, +}; + +interface InstallationStatusProps { + installStatus: EpmPackageInstallStatus | null | undefined; + showInstallationStatus?: boolean; +} + +export const getLineClampStyles = (lineClamp?: number) => + lineClamp + ? `-webkit-line-clamp: ${lineClamp};display: -webkit-box;-webkit-box-orient: vertical;overflow: hidden;` + : ''; + +export const shouldShowInstallationStatus = ({ + installStatus, + showInstallationStatus, +}: InstallationStatusProps) => + showInstallationStatus && + (installStatus === installationStatuses.Installed || + installStatus === installationStatuses.InstallFailed); + +export const InstallationStatus: React.FC = React.memo( + ({ installStatus, showInstallationStatus }) => { + const { euiTheme } = useEuiTheme(); + return shouldShowInstallationStatus({ installStatus, showInstallationStatus }) ? ( +
+ + +
+ ) : null; + } +); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index dd33d2643caa2..7f36f888b8dc8 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -5,12 +5,11 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { EuiBadge, EuiButton, - EuiCallOut, EuiCard, EuiFlexGroup, EuiFlexItem, @@ -35,8 +34,12 @@ import type { IntegrationCardItem } from '../screens/home'; import { InlineReleaseBadge, WithGuidedOnboardingTour } from '../../../components'; import { useStartServices, useIsGuidedOnboardingActive } from '../../../hooks'; import { INTEGRATIONS_BASE_PATH, INTEGRATIONS_PLUGIN_ID } from '../../../constants'; -import type { EpmPackageInstallStatus } from '../../../../../../common/types'; -import { installationStatuses } from '../../../../../../common/constants'; + +import { + InstallationStatus, + getLineClampStyles, + shouldShowInstallationStatus, +} from './installation_status'; export type PackageCardProps = IntegrationCardItem; @@ -49,24 +52,6 @@ const Card = styled(EuiCard)<{ isquickstart?: boolean; fixedCardHeight?: number fixedCardHeight ? `max-height: ${fixedCardHeight}px; overflow: hidden;` : ''}; `; -const installStatusMapToColor: Record< - EpmPackageInstallStatus, - { color: string; iconType: string; text: string } -> = { - installed: { - color: 'success', - iconType: 'check', - text: i18n.translate('xpack.fleet.packageCard.installedLabel', { defaultMessage: 'Installed' }), - }, - install_failed: { - color: 'warning', - iconType: 'warning', - text: i18n.translate('xpack.fleet.packageCard.installFailedLabel', { - defaultMessage: 'Install Failed', - }), - }, -}; - export function PackageCard({ description, name, @@ -88,7 +73,8 @@ export function PackageCard({ installStatus, onCardClick: onClickProp = undefined, isCollectionCard = false, - lineClamp, + titleLineClamp, + descriptionLineClamp, fixedCardHeight, }: PackageCardProps) { let releaseBadge: React.ReactNode | null = null; @@ -177,45 +163,6 @@ export function PackageCard({ ); } - const installStatusCallout = useMemo( - () => - showInstallationStatus && - (installStatus === installationStatuses.Installed || - installStatus === installationStatuses.InstallFailed) ? ( -
- - - } - color={installStatusMapToColor[installStatus].color} - /> -
- ) : undefined, - [showInstallationStatus, installStatus] - ); - const { application } = useStartServices(); const isGuidedOnboardingActive = useIsGuidedOnboardingActive(name); @@ -223,7 +170,12 @@ export function PackageCard({ if (url.startsWith(INTEGRATIONS_BASE_PATH)) { application.navigateToApp(INTEGRATIONS_PLUGIN_ID, { path: url.slice(INTEGRATIONS_BASE_PATH.length), - state: { fromIntegrations }, + state: { + fromIntegrations, + onCancelUrl: application.getUrlForApp('securitySolutionUI', { + path: '/get_started', + }), + }, }); } else if (url.startsWith('http') || url.startsWith('https')) { window.open(url, '_blank'); @@ -244,6 +196,7 @@ export function PackageCard({ - {installStatusCallout} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx index 2ce70002627c9..1a4abf7ecd06c 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx @@ -28,6 +28,7 @@ interface GridColumnProps { isLoading: boolean; showMissingIntegrationMessage?: boolean; showCardLabels?: boolean; + scrollElementId?: string; } const VirtualizedRow: React.FC<{ @@ -61,6 +62,7 @@ export const GridColumn = ({ showMissingIntegrationMessage = false, showCardLabels = false, isLoading, + scrollElementId, }: GridColumnProps) => { const itemsSizeRefs = useRef(new Map()); const listRef = useRef(null); @@ -107,6 +109,7 @@ export const GridColumn = ({ ); } + return ( <> {() => ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx index b73e7c92e855e..52be57a5aac77 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx @@ -70,6 +70,7 @@ export interface PackageListGridProps { showControls?: boolean; showSearchTools?: boolean; spacer?: boolean; + scrollElementId?: string; } export const PackageListGrid: FunctionComponent = ({ @@ -93,6 +94,7 @@ export const PackageListGrid: FunctionComponent = ({ showControls = true, showSearchTools = true, spacer = true, + scrollElementId, }) => { const localSearchRef = useLocalSearch(list, !!isLoading); @@ -276,6 +278,7 @@ export const PackageListGrid: FunctionComponent = ({ list={filteredPromotedList} showMissingIntegrationMessage={showMissingIntegrationMessage} showCardLabels={showCardLabels} + scrollElementId={scrollElementId} />
{showMissingIntegrationMessage && ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx index 271d408442538..dcefb21cec495 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx @@ -60,7 +60,8 @@ export interface IntegrationCardItem { isCollectionCard?: boolean; showInstallationStatus?: boolean; installStatus: EpmPackageInstallStatus | null | undefined; - lineClamp?: number; + titleLineClamp?: number; + descriptionLineClamp?: number; fixedCardHeight?: number; } diff --git a/x-pack/plugins/fleet/public/index.ts b/x-pack/plugins/fleet/public/index.ts index c0131c1fba17a..d75b66f1fe931 100644 --- a/x-pack/plugins/fleet/public/index.ts +++ b/x-pack/plugins/fleet/public/index.ts @@ -79,6 +79,7 @@ export const LazyPackagePolicyInputVarField = lazy(() => export type { PackageListGridProps } from './applications/integrations/sections/epm/components/package_list_grid'; export type { AvailablePackagesHookType } from './applications/integrations/sections/epm/screens/home/hooks/use_available_packages'; export type { IntegrationCardItem } from './applications/integrations/sections/epm/screens/home'; +export type { CategoryFacet } from './applications/integrations/sections/epm/screens/home/category_facets'; export const PackageList = () => { return import('./applications/integrations/sections/epm/components/package_list_grid'); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx index c4039955e4216..0996a1ced6380 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx @@ -10,8 +10,15 @@ import { EuiPanel, type EuiPanelProps } from '@elastic/eui'; export const OnboardingCardContentPanel = React.memo>( ({ children, ...panelProps }) => { return ( - - + + {children} diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx new file mode 100644 index 0000000000000..ac13662ebc02f --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx @@ -0,0 +1,68 @@ +/* + * 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, { useRef } from 'react'; + +import useAsyncRetry from 'react-use/lib/useAsyncRetry'; +import type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton, EuiCallOut, EuiSkeletonText } from '@elastic/eui'; +import { fetchAvailablePackagesHook } from './utils'; +import { PackageListGrid } from './package_list_grid'; +import { LOADING_SKELETON_HEIGHT } from './const'; + +export const AvailablePackages = React.memo( + ({ setComplete }: { setComplete: (complete: boolean) => void }) => { + const ref = useRef(null); + + const { + error: errorLoading, + retry: retryAsyncLoad, + loading: asyncLoading, + } = useAsyncRetry(async () => { + ref.current = await fetchAvailablePackagesHook(); + }); + + if (errorLoading) + return ( + +

+ +

+ { + if (!asyncLoading) retryAsyncLoad(); + }} + > + + +
+ ); + + if (asyncLoading || ref.current === null) + return ; + + return ; + } +); + +AvailablePackages.displayName = 'AvailablePackages'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts new file mode 100644 index 0000000000000..c1678db26708f --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts @@ -0,0 +1,61 @@ +/* + * 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 type { CategoryFacet } from '@kbn/fleet-plugin/public'; +import type { Tab } from './types'; + +export const FLEET_URL_QUERY = 'onboardingLink'; +export const SEARCH_FILTER_CATEGORIES: CategoryFacet[] = []; +export const SCROLL_ELEMENT_ID = 'integrations-scroll-container'; +export const WITH_SEARCH_BOX_HEIGHT = '517px'; +export const WITHOUT_SEARCH_BOX_HEIGHT = '462px'; +export const LOADING_SKELETON_HEIGHT = 10; // 10 lines of text +export const INTEGRATION_TABS: Tab[] = [ + { + id: 'recommended', + label: 'Recommended', + category: 'security', + iconType: 'starFilled', + showSearchTools: false, + }, + { + id: 'network', + label: 'Network', + category: 'security', + subCategory: 'network_security', + }, + { + id: 'user', + label: 'User', + category: 'security', + subCategory: 'iam', + }, + { + id: 'endpoint', + label: 'Endpoint', + category: 'security', + subCategory: 'edr_xdr', + }, + { + id: 'cloud', + label: 'Cloud', + category: 'security', + subCategory: 'cloudsecurity_cdr', + }, + { + id: 'threatIntel', + label: 'Threat Intel', + category: 'security', + subCategory: 'threat_intel', + }, + { + id: 'all', + label: 'All', + category: '', + }, +]; +export const DEFAULT_TAB = INTEGRATION_TABS[0]; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts index ba89a52a257f3..2f9e9946ed922 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts @@ -7,99 +7,47 @@ import { useMemo } from 'react'; import type { IntegrationCardItem } from '@kbn/fleet-plugin/public'; -import { isEmpty } from 'lodash'; -import { - APP_INTEGRATIONS_PATH, - APP_PATH, - ONBOARDING_PATH, -} from '../../../../../../common/constants'; -import { useKibana } from '../../../../../common/lib/kibana'; - -export interface CustomCard { - type: 'featured'; - name: string; -} - -function extractFeaturedCards(filteredCards: IntegrationCardItem[], featuredCardNames?: string[]) { - return filteredCards.reduce((acc: Record, card) => { - if (featuredCardNames?.includes(card.name)) { - acc[card.name] = card; - } - return acc; - }, {}); -} - -function getFilteredCards( - integrationsList: IntegrationCardItem[], - customCards?: string[], - basePath?: string, - installedIntegrationList?: IntegrationCardItem[] -) { - const securityIntegrationsList = integrationsList.map((card) => - addSecuritySpecificProps({ card, basePath, installedIntegrationList }) - ); - if (!customCards) { - return { featuredCards: {}, integrationCards: securityIntegrationsList }; - } - return { - featuredCards: extractFeaturedCards(securityIntegrationsList, customCards), - integrationCards: securityIntegrationsList, - }; -} - -function addPathParamToUrl(url: string, onboardingLink: string) { - const encoded = encodeURIComponent(onboardingLink); - if (url.indexOf('?') >= 0) { - return `${url}&onboardingLink=${encoded}`; - } - return `${url}?onboardingLink=${encoded}`; -} - -function getOnboardingPath(basePath?: string): string | null { - const onboardingPath = `${APP_PATH}${ONBOARDING_PATH}`; - const path = !isEmpty(basePath) ? `${basePath}/${onboardingPath}` : onboardingPath; - - return path; -} - -function addSecuritySpecificProps({ - basePath, - card, -}: { - basePath?: string; - card: IntegrationCardItem; - installedIntegrationList?: IntegrationCardItem[]; -}): IntegrationCardItem { - const onboardingLink = getOnboardingPath(basePath); - return { - ...card, - lineClamp: 3, - fixedCardHeight: 127, - showInstallationStatus: true, - url: - card.url.indexOf(APP_INTEGRATIONS_PATH) >= 0 && onboardingLink - ? addPathParamToUrl(card.url, onboardingLink) - : card.url, - }; -} +import { useKibana } from '../../../../../common/lib/kibana'; +import { getFilteredCards } from './utils'; +import { INTEGRATION_TABS } from './const'; -export function useIntegrationCardList({ +export const useIntegrationCardList = ({ integrationsList, - customCards, + customCardNames, }: { integrationsList: IntegrationCardItem[]; - customCards?: string[]; -}): IntegrationCardItem[] { + customCardNames?: string[] | undefined; +}): IntegrationCardItem[] => { const kibana = useKibana(); const basePath = kibana.services.http?.basePath.get(); const { featuredCards, integrationCards } = useMemo( - () => getFilteredCards(integrationsList, customCards, basePath), - [integrationsList, customCards, basePath] + () => getFilteredCards(integrationsList, customCardNames, basePath), + [integrationsList, customCardNames, basePath] ); - if (customCards && customCards.length > 0) { + if (customCardNames && customCardNames.length > 0) { return Object.values(featuredCards) ?? []; } return integrationCards ?? []; -} +}; + +export const useTabMetaData = (toggleIdSelected: string) => { + const selectedTab = useMemo( + () => INTEGRATION_TABS.find(({ id }) => id === toggleIdSelected), + [toggleIdSelected] + ); + const selectedCategory = selectedTab?.category ?? ''; + const selectedSubCategory = selectedTab?.subCategory; + const showSearchTools = selectedTab?.showSearchTools ?? true; + const customCardNames = useMemo(() => selectedTab?.customCardNames, [selectedTab]); + + return useMemo(() => { + return { + showSearchTools, + customCardNames, + selectedCategory, + selectedSubCategory, + }; + }, [showSearchTools, customCardNames, selectedCategory, selectedSubCategory]); +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index 5687ad6027a88..c972b6dedf0d5 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -4,238 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import React from 'react'; +import { AvailablePackages } from './available_packages'; -import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react'; -import useAsyncRetry from 'react-use/lib/useAsyncRetry'; - -import { - EuiButton, - EuiButtonGroup, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiSkeletonText, -} from '@elastic/eui'; -import type { - AvailablePackagesHookType, - IntegrationCardItem, - CategoryFacet, -} from '@kbn/fleet-plugin/public'; -import { noop } from 'lodash'; - -import { FormattedMessage } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; import type { OnboardingCardComponent } from '../../../../types'; import { OnboardingCardContentPanel } from '../common/card_content_panel'; -import { PackageList, fetchAvailablePackagesHook } from './utils'; -import { useIntegrationCardList } from './hooks'; - -interface Props { - /** - * A subset of either existing card names to feature, or virtual - * cards to display. The inclusion of CustomCards will override the default - * list functionality. - */ - onLoaded?: () => void; -} - -type WrapperProps = Props & { - useAvailablePackages: AvailablePackagesHookType; -}; - -const Loading = () => ; - -const categories: CategoryFacet[] = []; -const tabs = [ - { - id: 'featured', - label: 'Recommended', - category: '', - customCards: ['1password'], - iconType: 'starFilled', - }, - { - id: 'network', - label: 'Network', - category: 'security', - subCategory: 'network_security', - }, - { - id: 'user', - label: 'User', - category: 'security', - subCategory: 'iam', - }, - { - id: 'endpoint', - label: 'Endpoint', - category: 'security', - subCategory: 'edr_xdr', - }, - { - id: 'cloud', - label: 'Cloud', - category: 'security', - subCategory: 'cloudsecurity_cdr', - }, - { - id: 'threatIntel', - label: 'Threat Intel', - category: 'security', - subCategory: 'threat_intel', - }, - { - id: 'all', - label: 'All', - category: '', - }, -]; -const defaultTab = tabs[0]; - -export const IntegrationsCard: OnboardingCardComponent = ({ - setComplete, - checkCompleteMetadata, // this is undefined before the first checkComplete call finishes -}) => { - // TODO: implement. This is just for demo purposes +export const IntegrationsCard: OnboardingCardComponent = ({ setComplete }) => { return ( - + ); }; -const PackageListGridWrapper = ({ useAvailablePackages, onLoaded }: WrapperProps) => { - const [toggleIdSelected, setToggleIdSelected] = useState(defaultTab.id); - const onChange = (optionId: string) => { - setToggleIdSelected(optionId); - }; - - const { - filteredCards, - isLoading, - searchTerm: searchQuery, - setCategory, - setSearchTerm, - setSelectedSubCategory, - } = useAvailablePackages({ - prereleaseIntegrationsEnabled: false, - }); - - const selectedTab = useMemo( - () => tabs.find(({ id }) => id === toggleIdSelected), - [toggleIdSelected] - ); - - const selectedCategory = selectedTab?.category ?? ''; - const selectedSubCategory = selectedTab?.subCategory; - const customCards = useMemo(() => selectedTab?.customCards, [selectedTab]); - - useEffect(() => { - setCategory(selectedCategory); - setSelectedSubCategory(selectedSubCategory); - }); - - const list: IntegrationCardItem[] = useIntegrationCardList({ - integrationsList: filteredCards, - customCards, - }); - - useEffect(() => { - if (!isLoading) { - onLoaded?.(); - } - }, [isLoading, onLoaded]); - - if (isLoading) return ; - - return ( - - - onChange(id)} - color="primary" - buttonSize="compressed" - /> - - - }> - - - - - ); -}; - -const WithAvailablePackages = React.forwardRef((props: Props) => { - const ref = useRef(null); - - const { - error: errorLoading, - retry: retryAsyncLoad, - loading: asyncLoading, - } = useAsyncRetry(async () => { - ref.current = await fetchAvailablePackagesHook(); - }); - - if (errorLoading) - return ( - -

- -

- { - if (!asyncLoading) retryAsyncLoad(); - }} - > - - -
- ); - - if (asyncLoading || ref.current === null) return ; - - return ; -}); - // eslint-disable-next-line import/no-default-export export default IntegrationsCard; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx new file mode 100644 index 0000000000000..aeea23aec6dd8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -0,0 +1,144 @@ +/* + * 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, { Suspense, useCallback, useEffect, useRef, useState } from 'react'; + +import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiSkeletonText } from '@elastic/eui'; +import type { AvailablePackagesHookType, IntegrationCardItem } from '@kbn/fleet-plugin/public'; +import { noop } from 'lodash'; + +import { css } from '@emotion/react'; +import { PackageList } from './utils'; +import { useIntegrationCardList, useTabMetaData } from './hooks'; +import { + useStoredIntegrationSearchTerm, + useStoredIntegrationTabId, +} from '../../../../hooks/use_stored_state'; +import { useOnboardingContext } from '../../../onboarding_context'; +import { + DEFAULT_TAB, + INTEGRATION_TABS, + LOADING_SKELETON_HEIGHT, + SCROLL_ELEMENT_ID, + SEARCH_FILTER_CATEGORIES, + WITHOUT_SEARCH_BOX_HEIGHT, + WITH_SEARCH_BOX_HEIGHT, +} from './const'; + +interface WrapperProps { + useAvailablePackages: AvailablePackagesHookType; + setComplete: (complete: boolean) => void; +} +export const PackageListGrid = React.memo(({ useAvailablePackages, setComplete }: WrapperProps) => { + const { spaceId } = useOnboardingContext(); + const scrollElement = useRef(null); + const [selectedTabId, setSelectedTabIdToStorage] = useStoredIntegrationTabId( + spaceId, + DEFAULT_TAB.id + ); + const [searchTermFromStorage, setSearchTermToStorage] = useStoredIntegrationSearchTerm(spaceId); + const [toggleIdSelected, setToggleIdSelected] = useState(selectedTabId); + const onChange = (optionId: string) => { + setToggleIdSelected(optionId); + setSelectedTabIdToStorage(optionId); + }; + + const { + filteredCards, + isLoading, + searchTerm: searchQuery, + setCategory, + setSearchTerm, + setSelectedSubCategory, + } = useAvailablePackages({ + prereleaseIntegrationsEnabled: false, + }); + + const { showSearchTools, customCardNames, selectedCategory, selectedSubCategory } = + useTabMetaData(toggleIdSelected); + + useEffect(() => { + setCategory(selectedCategory); + setSelectedSubCategory(selectedSubCategory); + setSearchTerm(''); // Reset search term when changing tabs + }, [ + searchTermFromStorage, + selectedCategory, + selectedSubCategory, + setCategory, + setSearchTerm, + setSelectedSubCategory, + ]); + + const list: IntegrationCardItem[] = useIntegrationCardList({ + integrationsList: filteredCards, + customCardNames, + }); + + const onSearchTermChanged = useCallback( + (searchTerm: string) => { + setSearchTerm(searchTerm); + setSearchTermToStorage(searchTerm); + }, + [setSearchTerm, setSearchTermToStorage] + ); + + if (isLoading) return ; + + return ( + + + onChange(id)} + options={INTEGRATION_TABS} + type="single" + /> + + + }> + + + + + ); +}); + +PackageListGrid.displayName = 'PackageListGrid'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts new file mode 100644 index 0000000000000..d85f203250ba4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts @@ -0,0 +1,15 @@ +/* + * 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. + */ +export interface Tab { + category: string; + customCardNames?: string[]; // custom card name e.g.: 1password + iconType?: string; + id: string; + label: string; + showSearchTools?: boolean; + subCategory?: string; +} diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts index 7abcb19c06b63..fe8cf7b851e88 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts @@ -6,7 +6,15 @@ */ import { lazy } from 'react'; -import type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public'; +import type { AvailablePackagesHookType, IntegrationCardItem } from '@kbn/fleet-plugin/public'; +import { isEmpty } from 'lodash'; + +import { + APP_INTEGRATIONS_PATH, + APP_PATH, + ONBOARDING_PATH, +} from '../../../../../../common/constants'; +import { FLEET_URL_QUERY } from './const'; export const PackageList = lazy(async () => ({ default: await import('@kbn/fleet-plugin/public') @@ -18,3 +26,71 @@ export const fetchAvailablePackagesHook = (): Promise import('@kbn/fleet-plugin/public') .then((module) => module.AvailablePackagesHook()) .then((hook) => hook.useAvailablePackages); + +export const extractFeaturedCards = ( + filteredCards: IntegrationCardItem[], + featuredCardNames?: string[] +) => { + return filteredCards.reduce((acc: Record, card) => { + if (featuredCardNames?.includes(card.name)) { + acc[card.name] = card; + } + return acc; + }, {}); +}; + +export const getFilteredCards = ( + integrationsList: IntegrationCardItem[], + customCards?: string[], + basePath?: string, + installedIntegrationList?: IntegrationCardItem[] +) => { + const securityIntegrationsList = integrationsList.map((card) => + addSecuritySpecificProps({ card, basePath, installedIntegrationList }) + ); + if (!customCards) { + return { featuredCards: {}, integrationCards: securityIntegrationsList }; + } + + return { + featuredCards: extractFeaturedCards(securityIntegrationsList, customCards), + integrationCards: securityIntegrationsList, + }; +}; + +const addPathParamToUrl = (url: string, onboardingLink: string) => { + const encoded = encodeURIComponent(onboardingLink); + if (url.indexOf('?') >= 0) { + return `${url}&${FLEET_URL_QUERY}=${encoded}`; + } + return `${url}?${FLEET_URL_QUERY}=${encoded}`; +}; + +const getOnboardingPath = (basePath?: string): string | null => { + const onboardingPath = `${APP_PATH}${ONBOARDING_PATH}`; + const path = !isEmpty(basePath) ? `${basePath}/${onboardingPath}` : onboardingPath; + + return path; +}; + +const addSecuritySpecificProps = ({ + basePath, + card, +}: { + basePath?: string; + card: IntegrationCardItem; + installedIntegrationList?: IntegrationCardItem[]; +}): IntegrationCardItem => { + const onboardingLink = getOnboardingPath(basePath); + return { + ...card, + titleLineClamp: 1, + descriptionLineClamp: 3, + fixedCardHeight: 127, + showInstallationStatus: true, + url: + card.url.indexOf(APP_INTEGRATIONS_PATH) >= 0 && onboardingLink + ? addPathParamToUrl(card.url, onboardingLink) + : card.url, + }; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts b/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts index d12770eeeafc7..504f3431906b6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts +++ b/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts @@ -13,6 +13,9 @@ const LocalStorageKey = { videoVisited: 'ONBOARDING_HUB.VIDEO_VISITED', completeCards: 'ONBOARDING_HUB.COMPLETE_CARDS', expandedCard: 'ONBOARDING_HUB.EXPANDED_CARD', + selectedIntegrationTabId: 'ONBOARDING_HUB.SELECTED_INTEGRATION_TAB_ID', + IntegrationSearchTerm: 'ONBOARDING_HUB.INTEGRATION_SEARCH_TERM', + IntegrationScrollTop: 'ONBOARDING_HUB.INTEGRATION_SCROLL_TOP', } as const; /** @@ -43,3 +46,21 @@ export const useStoredExpandedCardId = (spaceId: string) => `${LocalStorageKey.expandedCard}.${spaceId}`, null ); + +/** + * Stores either the video card has been visited or not, per space + */ +export const useStoredHasVideoVisited = (spaceId: string) => + useDefinedLocalStorage(`${LocalStorageKey.videoVisited}.${spaceId}`, false); + +export const useStoredIntegrationTabId = (spaceId: string, defaultSelectedTabId: string) => + useDefinedLocalStorage( + `${LocalStorageKey.selectedIntegrationTabId}.${spaceId}`, + defaultSelectedTabId + ); + +export const useStoredIntegrationSearchTerm = (spaceId: string) => + useDefinedLocalStorage( + `${LocalStorageKey.IntegrationSearchTerm}.${spaceId}`, + null + ); From e64e0aa40173c0f66cbde8538b5bb8e6aff4f1a2 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Fri, 20 Sep 2024 17:11:51 +0100 Subject: [PATCH 05/35] integration badge --- .../cards/common/card_callout.tsx | 2 +- .../cards/integrations/integrations_card.tsx | 46 +++++++++++++++++-- .../integrations_check_complete.ts | 28 +++++++++-- .../hooks/use_completed_cards.ts | 10 ++-- 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_callout.tsx index e3afd20b3aa10..b53952d24d3b6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_callout.tsx @@ -11,7 +11,7 @@ import type { EuiCallOutProps, IconType } from '@elastic/eui'; import { useCardCallOutStyles } from './card_callout.styles'; export interface CardCallOutProps { - text: string; + text: React.ReactNode; color?: EuiCallOutProps['color']; icon?: IconType; action?: React.ReactNode; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index c972b6dedf0d5..b30749abd6442 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -5,13 +5,53 @@ * 2.0. */ import React from 'react'; -import { AvailablePackages } from './available_packages'; - +import { EuiSpacer, EuiLink, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import type { OnboardingCardComponent } from '../../../../types'; import { OnboardingCardContentPanel } from '../common/card_content_panel'; -export const IntegrationsCard: OnboardingCardComponent = ({ setComplete }) => { +import { CardCallOut } from '../common/card_callout'; +import { AvailablePackages } from './available_packages'; +import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; + +export const IntegrationsCard: OnboardingCardComponent = ({ + setComplete, + checkCompleteMetadata, // this is undefined before the first checkComplete call finishes +}) => { + const integrationsInstalled: number | undefined = checkCompleteMetadata?.integrationsInstalled; + const { onClick } = useAddIntegrationsUrl(); + return ( + {integrationsInstalled && ( + <> + + {i18n.translate( + 'xpack.securitySolution.onboarding.integrationsCard.callout.completeText', + { + defaultMessage: + '{count} {count, plural, one {integration has} other {integrations have}} been added', + values: { count: integrationsInstalled }, + } + )}{' '} + + {i18n.translate( + 'xpack.securitySolution.onboarding.integrationsCard.button.completeLink', + { + defaultMessage: 'Manage integrations', + } + )} + + + + } + /> + + + + )} ); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts index ca58eb5cc67fa..fe1fd7a473c29 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts @@ -8,8 +8,9 @@ import type { GetPackagesResponse } from '@kbn/fleet-plugin/public'; import { EPM_PACKAGES_MANY, installationStatuses } from '@kbn/fleet-plugin/public'; import type { HttpSetup } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; import type { OnboardingCardCheckComplete } from '../../../../types'; -import { getDummyAdditionalBadge } from './integrations_header_badges'; +// import { getDummyAdditionalBadge } from './integrations_header_badges'; export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async ({ http, @@ -19,10 +20,31 @@ export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async const data = await http.get(EPM_PACKAGES_MANY, { version: '2023-10-31', }); - const installed = (data?.items || []).filter( + const installed = data?.items?.filter( (pkg) => pkg.status === installationStatuses.Installed || pkg.status === installationStatuses.InstallFailed ); - return installed.length > 0; + const isComplete = installed && installed.length > 0; + + if (!isComplete) { + return { + isComplete, + }; + } + + return { + isComplete, + completeBadgeText: i18n.translate( + 'xpack.securitySolution.onboarding.integrationsCard.badge.completeText', + { + defaultMessage: '{count} {count, plural, one {integration} other {integrations}} added', + values: { count: installed.length }, + } + ), + // additionalBadges: [getDummyAdditionalBadge()], + metadata: { + integrationsInstalled: installed.length, + }, + }; }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts index 88f6e7ca11b90..1b854503b4479 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts @@ -15,6 +15,7 @@ import type { OnboardingGroupConfig, } from '../../../types'; import { useOnboardingContext } from '../../onboarding_context'; +import { useKibana } from '../../../../common/lib/kibana'; export type IsCardComplete = (cardId: OnboardingCardId) => boolean; export type SetCardComplete = ( @@ -33,6 +34,7 @@ export type CardCheckCompleteResult = Partial { const { spaceId, reportCardComplete } = useOnboardingContext(); + const { http } = useKibana().services; // Use stored state to keep localStorage in sync, and a local state to avoid unnecessary re-renders. const [storedCompleteCardIds, setStoredCompleteCardIds] = useStoredCompletedCardIds(spaceId); @@ -111,22 +113,22 @@ export const useCompletedCards = (cardsGroupConfig: OnboardingGroupConfig[]) => const cardConfig = cardsWithAutoCheck.find(({ id }) => id === cardId); if (cardConfig) { - cardConfig.checkComplete?.().then((checkCompleteResult) => { + cardConfig.checkComplete?.({ http }).then((checkCompleteResult) => { processCardCheckCompleteResult(cardId, checkCompleteResult); }); } }, - [cardsWithAutoCheck, processCardCheckCompleteResult] + [cardsWithAutoCheck, http, processCardCheckCompleteResult] ); // Initial auto-check for all cards, it should run only once, after cardsGroupConfig is properly populated useEffect(() => { cardsWithAutoCheck.map((card) => - card.checkComplete?.().then((checkCompleteResult) => { + card.checkComplete?.({ http }).then((checkCompleteResult) => { processCardCheckCompleteResult(card.id, checkCompleteResult); }) ); - }, [cardsWithAutoCheck, processCardCheckCompleteResult]); + }, [cardsWithAutoCheck, http, processCardCheckCompleteResult]); return { isCardComplete, From f66bbd89c042cdc110d8fd255b63e6f920352ac4 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 24 Sep 2024 14:06:11 +0100 Subject: [PATCH 06/35] add customised cancellation link --- .../epm/components/installation_status.tsx | 6 +-- .../sections/epm/screens/detail/index.tsx | 22 +++++++- .../detail/utils/get_install_route_options.ts | 7 ++- .../cards/integrations/const.ts | 3 +- .../cards/integrations/integrations_card.tsx | 50 +++++++++++-------- .../cards/integrations/package_list_grid.tsx | 48 +++++++++++------- .../cards/integrations/utils.ts | 9 ++-- 7 files changed, 95 insertions(+), 50 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx index 72a2b4af3cc3b..5036e74ae74de 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx @@ -59,9 +59,9 @@ export const InstallationStatus: React.FC = React.memo( css={` position: absolute; border-radius: 0 0 ${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium}; - bottom: 1px; - left: 1px; - width: calc(100% - 2px); + bottom: 0; + left: 0; + width: 100%; overflow: hidden; `} > diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index f826b4f5c308e..6772ff6c3aec4 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -87,6 +87,7 @@ import { DocumentationPage, hasDocumentation } from './documentation'; import { Configs } from './configs'; import './index.scss'; +import type { InstallPkgRouteOptions } from './utils/get_install_route_options'; export type DetailViewPanelName = | 'overview' @@ -135,6 +136,8 @@ export function Detail() { const queryParams = useMemo(() => new URLSearchParams(search), [search]); const integration = useMemo(() => queryParams.get('integration'), [queryParams]); const prerelease = useMemo(() => Boolean(queryParams.get('prerelease')), [queryParams]); + const onboardingLink = useMemo(() => queryParams.get('onboardingLink'), [queryParams]); + const onboardingAppId = useMemo(() => queryParams.get('onboardingAppId'), [queryParams]); const authz = useAuthz(); const canAddAgent = authz.fleet.addAgents; @@ -388,7 +391,7 @@ export function Detail() { hash, }); - const navigateOptions = getInstallPkgRouteOptions({ + const defaultNavigateOptions: InstallPkgRouteOptions = getInstallPkgRouteOptions({ agentPolicyId: agentPolicyIdFromContext, currentPath, integration, @@ -399,6 +402,21 @@ export function Detail() { pkgkey, }); + const navigateOptions: InstallPkgRouteOptions = + onboardingAppId && onboardingLink + ? [ + defaultNavigateOptions[0], + { + ...defaultNavigateOptions[1], + state: { + ...(defaultNavigateOptions[1]?.state ?? {}), + onCancelNavigateTo: [onboardingAppId, { path: onboardingLink }], + onCancelUrl: onboardingLink, + }, + }, + ] + : defaultNavigateOptions; + services.application.navigateToApp(...navigateOptions); }, [ @@ -410,6 +428,8 @@ export function Detail() { isExperimentalAddIntegrationPageEnabled, isFirstTimeAgentUser, isGuidedOnboardingActive, + onboardingAppId, + onboardingLink, pathname, pkgkey, search, diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.ts index 54db4346582bd..4c2cb97e295ad 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.ts +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.ts @@ -32,6 +32,11 @@ interface GetInstallPkgRouteOptionsParams { isGuidedOnboardingActive: boolean; } +export type InstallPkgRouteOptions = [ + string, + { path: string; state: CreatePackagePolicyRouteState } +]; + const isPackageExemptFromStepsLayout = (pkgkey: string) => EXCLUDED_PACKAGES.some((pkgname) => pkgkey.startsWith(pkgname)); /* @@ -47,7 +52,7 @@ export const getInstallPkgRouteOptions = ({ isCloud, isExperimentalAddIntegrationPageEnabled, isGuidedOnboardingActive, -}: GetInstallPkgRouteOptionsParams): [string, { path: string; state: unknown }] => { +}: GetInstallPkgRouteOptionsParams): InstallPkgRouteOptions => { const integrationOpts: { integration?: string } = integration ? { integration } : {}; const packageExemptFromStepsLayout = isPackageExemptFromStepsLayout(pkgkey); const useMultiPageLayout = diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts index c1678db26708f..11ec200348722 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts @@ -8,7 +8,8 @@ import type { CategoryFacet } from '@kbn/fleet-plugin/public'; import type { Tab } from './types'; -export const FLEET_URL_QUERY = 'onboardingLink'; +export const ONBOARDING_LINK = 'onboardingLink'; +export const ONBOARDING_APP_ID = 'onboardingAppId'; export const SEARCH_FILTER_CATEGORIES: CategoryFacet[] = []; export const SCROLL_ELEMENT_ID = 'integrations-scroll-container'; export const WITH_SEARCH_BOX_HEIGHT = '517px'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index b30749abd6442..9831234fcafd0 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -5,20 +5,21 @@ * 2.0. */ import React from 'react'; -import { EuiSpacer, EuiLink, EuiIcon } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiSpacer, EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; import type { OnboardingCardComponent } from '../../../../types'; import { OnboardingCardContentPanel } from '../common/card_content_panel'; import { CardCallOut } from '../common/card_callout'; import { AvailablePackages } from './available_packages'; import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; +import { LinkAnchor } from '../../../../../common/components/links'; export const IntegrationsCard: OnboardingCardComponent = ({ setComplete, checkCompleteMetadata, // this is undefined before the first checkComplete call finishes }) => { const integrationsInstalled: number | undefined = checkCompleteMetadata?.integrationsInstalled; - const { onClick } = useAddIntegrationsUrl(); + const { href, onClick } = useAddIntegrationsUrl(); return ( @@ -27,25 +28,30 @@ export const IntegrationsCard: OnboardingCardComponent = ({ - {i18n.translate( - 'xpack.securitySolution.onboarding.integrationsCard.callout.completeText', - { - defaultMessage: - '{count} {count, plural, one {integration has} other {integrations have}} been added', - values: { count: integrationsInstalled }, - } - )}{' '} - - {i18n.translate( - 'xpack.securitySolution.onboarding.integrationsCard.button.completeLink', - { - defaultMessage: 'Manage integrations', - } - )} - - - + + ), + desc2: ( + + + + + ), + }} + /> } /> diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx index aeea23aec6dd8..e026fdf238fce 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -31,9 +31,8 @@ import { interface WrapperProps { useAvailablePackages: AvailablePackagesHookType; - setComplete: (complete: boolean) => void; } -export const PackageListGrid = React.memo(({ useAvailablePackages, setComplete }: WrapperProps) => { +export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProps) => { const { spaceId } = useOnboardingContext(); const scrollElement = useRef(null); const [selectedTabId, setSelectedTabIdToStorage] = useStoredIntegrationTabId( @@ -42,15 +41,18 @@ export const PackageListGrid = React.memo(({ useAvailablePackages, setComplete } ); const [searchTermFromStorage, setSearchTermToStorage] = useStoredIntegrationSearchTerm(spaceId); const [toggleIdSelected, setToggleIdSelected] = useState(selectedTabId); - const onChange = (optionId: string) => { - setToggleIdSelected(optionId); - setSelectedTabIdToStorage(optionId); - }; + const onTabChange = useCallback( + (id: string) => { + setToggleIdSelected(id); + setSelectedTabIdToStorage(id); + }, + [setToggleIdSelected, setSelectedTabIdToStorage] + ); const { filteredCards, isLoading, - searchTerm: searchQuery, + searchTerm, setCategory, setSearchTerm, setSelectedSubCategory, @@ -61,17 +63,31 @@ export const PackageListGrid = React.memo(({ useAvailablePackages, setComplete } const { showSearchTools, customCardNames, selectedCategory, selectedSubCategory } = useTabMetaData(toggleIdSelected); + const onSearchTermChanged = useCallback( + (searchQuery: string) => { + setSearchTerm(searchQuery); + setSearchTermToStorage(searchQuery); + }, + [setSearchTerm, setSearchTermToStorage] + ); + useEffect(() => { setCategory(selectedCategory); setSelectedSubCategory(selectedSubCategory); - setSearchTerm(''); // Reset search term when changing tabs + if (!showSearchTools) { + // If search tools are not shown, clear the search term to avoid unexpected filtering + onSearchTermChanged(''); + } }, [ + onSearchTermChanged, searchTermFromStorage, selectedCategory, selectedSubCategory, setCategory, setSearchTerm, setSelectedSubCategory, + showSearchTools, + toggleIdSelected, ]); const list: IntegrationCardItem[] = useIntegrationCardList({ @@ -79,15 +95,9 @@ export const PackageListGrid = React.memo(({ useAvailablePackages, setComplete } customCardNames, }); - const onSearchTermChanged = useCallback( - (searchTerm: string) => { - setSearchTerm(searchTerm); - setSearchTermToStorage(searchTerm); - }, - [setSearchTerm, setSearchTermToStorage] - ); - - if (isLoading) return ; + if (isLoading) { + return ; + } return ( onChange(id)} + onChange={onTabChange} options={INTEGRATION_TABS} type="single" /> @@ -123,7 +133,7 @@ export const PackageListGrid = React.memo(({ useAvailablePackages, setComplete } categories={SEARCH_FILTER_CATEGORIES} // We do not want to show categories and subcategories as the search bar filter list={list} scrollElementId={SCROLL_ELEMENT_ID} - searchTerm={searchQuery ?? ''} + searchTerm={searchTerm} selectedCategory={selectedCategory} selectedSubCategory={selectedSubCategory} setCategory={setCategory} diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts index fe8cf7b851e88..7cc92bbd72884 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts @@ -12,9 +12,10 @@ import { isEmpty } from 'lodash'; import { APP_INTEGRATIONS_PATH, APP_PATH, + APP_UI_ID, ONBOARDING_PATH, } from '../../../../../../common/constants'; -import { FLEET_URL_QUERY } from './const'; +import { ONBOARDING_APP_ID, ONBOARDING_LINK } from './const'; export const PackageList = lazy(async () => ({ default: await import('@kbn/fleet-plugin/public') @@ -60,10 +61,12 @@ export const getFilteredCards = ( const addPathParamToUrl = (url: string, onboardingLink: string) => { const encoded = encodeURIComponent(onboardingLink); + const paramsString = `${ONBOARDING_LINK}=${encoded}&${ONBOARDING_APP_ID}=${APP_UI_ID}`; + if (url.indexOf('?') >= 0) { - return `${url}&${FLEET_URL_QUERY}=${encoded}`; + return `${url}&${paramsString}`; } - return `${url}?${FLEET_URL_QUERY}=${encoded}`; + return `${url}?${paramsString}`; }; const getOnboardingPath = (basePath?: string): string | null => { From a3ffd1e4b17472b058467abfbe472704d14f4360 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 24 Sep 2024 17:48:20 +0100 Subject: [PATCH 07/35] agent still required --- .../sections/epm/components/package_card.tsx | 7 +-- .../cards/integrations/available_packages.tsx | 2 +- .../cards/integrations/integrations_card.tsx | 30 ++++++++++-- .../integrations_check_complete.ts | 47 ++++++++++++++----- .../integrations_header_badges.tsx | 30 ++++++++++-- .../hooks/use_completed_cards.ts | 14 ++++-- .../public/onboarding/types.ts | 15 ++++-- 7 files changed, 110 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index 7f36f888b8dc8..10f26bb1a917d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -170,12 +170,7 @@ export function PackageCard({ if (url.startsWith(INTEGRATIONS_BASE_PATH)) { application.navigateToApp(INTEGRATIONS_PLUGIN_ID, { path: url.slice(INTEGRATIONS_BASE_PATH.length), - state: { - fromIntegrations, - onCancelUrl: application.getUrlForApp('securitySolutionUI', { - path: '/get_started', - }), - }, + state: { fromIntegrations }, }); } else if (url.startsWith('http') || url.startsWith('https')) { window.open(url, '_blank'); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx index ac13662ebc02f..26f1fcf04f895 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx @@ -61,7 +61,7 @@ export const AvailablePackages = React.memo( if (asyncLoading || ref.current === null) return ; - return ; + return ; } ); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index 9831234fcafd0..19dad9a07fee0 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiSpacer, EuiIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import type { OnboardingCardComponent } from '../../../../types'; @@ -13,20 +13,27 @@ import { CardCallOut } from '../common/card_callout'; import { AvailablePackages } from './available_packages'; import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; import { LinkAnchor } from '../../../../../common/components/links'; +import { useKibana } from '../../../../../common/lib/kibana'; export const IntegrationsCard: OnboardingCardComponent = ({ setComplete, checkCompleteMetadata, // this is undefined before the first checkComplete call finishes }) => { - const integrationsInstalled: number | undefined = checkCompleteMetadata?.integrationsInstalled; + const integrationsInstalled: number | undefined = checkCompleteMetadata?.integrationsInstalled as + | number + | undefined; const { href, onClick } = useAddIntegrationsUrl(); + const { navigateToApp } = useKibana().services.application; + const onAddAgentClick = useCallback(() => { + navigateToApp('fleet', { path: '/agents' }); + }, [navigateToApp]); return ( {integrationsInstalled && ( <> + ) : ( ), - desc2: ( + desc2: checkCompleteMetadata?.agentStillRequired ? ( + + + + + ) : ( Promise; }) => { - const data = await http.get(EPM_PACKAGES_MANY, { + const packageData = await http.get(EPM_PACKAGES_MANY, { version: '2023-10-31', }); - const installed = data?.items?.filter( + + const agentsAvailable = await lastValueFrom( + data.search.search({ + params: { index: `logs-elastic_agent*`, body: { size: 1 } }, + }) + ); + + const installed = packageData?.items?.filter( (pkg) => pkg.status === installationStatuses.Installed || pkg.status === installationStatuses.InstallFailed ); const isComplete = installed && installed.length > 0; + const agentStillRequired = + isComplete && + agentsAvailable?.rawResponse?.hits?.total != null && + agentsAvailable?.rawResponse?.hits?.total === 0; + + const completeBadgeText = i18n.translate( + 'xpack.securitySolution.onboarding.integrationsCard.badge.completeText', + { + defaultMessage: '{count} {count, plural, one {integration} other {integrations}} added', + values: { count: installed.length }, + } + ); if (!isComplete) { return { @@ -35,16 +60,14 @@ export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async return { isComplete, - completeBadgeText: i18n.translate( - 'xpack.securitySolution.onboarding.integrationsCard.badge.completeText', - { - defaultMessage: '{count} {count, plural, one {integration} other {integrations}} added', - values: { count: installed.length }, - } - ), - // additionalBadges: [getDummyAdditionalBadge()], + completeBadgeText: getCompleteBadgeWithTooltip({ + agentStillRequired, + navigateToApp, + completeBadgeText, + }), metadata: { integrationsInstalled: installed.length, + agentStillRequired, }, }; }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_header_badges.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_header_badges.tsx index 3eb9372935f7c..be65c5326127f 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_header_badges.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_header_badges.tsx @@ -6,8 +6,32 @@ */ import React from 'react'; -import { EuiBadge } from '@elastic/eui'; +import { EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { NavigateToAppOptions } from '@kbn/core/public'; -export const getDummyAdditionalBadge = () => { - return {'Dummy badge'}; +export const getCompleteBadgeWithTooltip = ({ + agentStillRequired, + navigateToApp, + completeBadgeText, +}: { + agentStillRequired: boolean; + navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise; + completeBadgeText: string; +}) => { + return agentStillRequired ? ( + + } + > + <>{completeBadgeText} + + ) : ( + completeBadgeText + ); }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts index 1b854503b4479..dcd407e16953e 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts @@ -34,7 +34,11 @@ export type CardCheckCompleteResult = Partial { const { spaceId, reportCardComplete } = useOnboardingContext(); - const { http } = useKibana().services; + const { + http, + data, + application: { navigateToApp }, + } = useKibana().services; // Use stored state to keep localStorage in sync, and a local state to avoid unnecessary re-renders. const [storedCompleteCardIds, setStoredCompleteCardIds] = useStoredCompletedCardIds(spaceId); @@ -113,22 +117,22 @@ export const useCompletedCards = (cardsGroupConfig: OnboardingGroupConfig[]) => const cardConfig = cardsWithAutoCheck.find(({ id }) => id === cardId); if (cardConfig) { - cardConfig.checkComplete?.({ http }).then((checkCompleteResult) => { + cardConfig.checkComplete?.({ http, data, navigateToApp }).then((checkCompleteResult) => { processCardCheckCompleteResult(cardId, checkCompleteResult); }); } }, - [cardsWithAutoCheck, http, processCardCheckCompleteResult] + [cardsWithAutoCheck, http, data, navigateToApp, processCardCheckCompleteResult] ); // Initial auto-check for all cards, it should run only once, after cardsGroupConfig is properly populated useEffect(() => { cardsWithAutoCheck.map((card) => - card.checkComplete?.({ http }).then((checkCompleteResult) => { + card.checkComplete?.({ http, data, navigateToApp }).then((checkCompleteResult) => { processCardCheckCompleteResult(card.id, checkCompleteResult); }) ); - }, [cardsWithAutoCheck, http, processCardCheckCompleteResult]); + }, [cardsWithAutoCheck, http, data, navigateToApp, processCardCheckCompleteResult]); return { isCardComplete, diff --git a/x-pack/plugins/security_solution/public/onboarding/types.ts b/x-pack/plugins/security_solution/public/onboarding/types.ts index b5f0c53ecb27e..eaec4b9a1bec6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/types.ts +++ b/x-pack/plugins/security_solution/public/onboarding/types.ts @@ -8,8 +8,9 @@ import type React from 'react'; import type { IconType } from '@elastic/eui'; import type { LicenseType } from '@kbn/licensing-plugin/public'; -import type { HttpSetup } from '@kbn/core/public'; +import type { HttpSetup, NavigateToAppOptions } from '@kbn/core/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { OnboardingCardId } from './constants'; import type { RequiredCapabilities } from '../common/lib/capabilities'; @@ -17,7 +18,7 @@ export interface CheckCompleteResult { /** * Optional custom badge text replacement for the card complete badge in the card header. */ - completeBadgeText?: string; + completeBadgeText?: string | React.ReactNode; /** * Optional badges to prepend to the card complete badge in the card header, regardless of completion status. */ @@ -64,7 +65,15 @@ export type OnboardingCardComponent = React.ComponentType<{ checkCompleteMetadata?: Record; }>; -export type OnboardingCardCheckComplete = ({ http }: { http: HttpSetup }) => Promise; +export type OnboardingCardCheckComplete = ({ + http, + data, + navigateToApp, +}: { + http: HttpSetup; + data: DataPublicPluginStart; + navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise; +}) => Promise; export interface OnboardingCardConfig { id: OnboardingCardId; From 25943e02314cabca631e9538e3aeda28eb8899b1 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 24 Sep 2024 18:35:47 +0100 Subject: [PATCH 08/35] display only 9 cards in the first tab --- .../components/onboarding_body/cards/integrations/const.ts | 1 + .../components/onboarding_body/cards/integrations/hooks.ts | 4 +++- .../cards/integrations/integrations_card.tsx | 5 ++--- .../cards/integrations/package_list_grid.tsx | 7 ++++--- .../components/onboarding_body/cards/integrations/types.ts | 1 + 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts index 11ec200348722..58e4b09609470 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts @@ -22,6 +22,7 @@ export const INTEGRATION_TABS: Tab[] = [ category: 'security', iconType: 'starFilled', showSearchTools: false, + overflow: 'hidden', }, { id: 'network', diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts index 2f9e9946ed922..0a244a85140f9 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts @@ -41,6 +41,7 @@ export const useTabMetaData = (toggleIdSelected: string) => { const selectedSubCategory = selectedTab?.subCategory; const showSearchTools = selectedTab?.showSearchTools ?? true; const customCardNames = useMemo(() => selectedTab?.customCardNames, [selectedTab]); + const overflow = selectedTab?.overflow ?? 'scroll'; return useMemo(() => { return { @@ -48,6 +49,7 @@ export const useTabMetaData = (toggleIdSelected: string) => { customCardNames, selectedCategory, selectedSubCategory, + overflow, }; - }, [showSearchTools, customCardNames, selectedCategory, selectedSubCategory]); + }, [showSearchTools, customCardNames, selectedCategory, selectedSubCategory, overflow]); }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index 19dad9a07fee0..fe88678ee3c47 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -38,7 +38,7 @@ export const IntegrationsCard: OnboardingCardComponent = ({ - ) : ( @@ -67,9 +66,9 @@ export const IntegrationsCard: OnboardingCardComponent = ({ id="xpack.securitySolution.onboarding.integrationsCard.button.completeLink" defaultMessage="Manage integrations" /> - ), + icon: , }} /> } diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx index e026fdf238fce..71622c467611b 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -34,7 +34,7 @@ interface WrapperProps { } export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProps) => { const { spaceId } = useOnboardingContext(); - const scrollElement = useRef(null); + const scrollElement = useRef(null); const [selectedTabId, setSelectedTabIdToStorage] = useStoredIntegrationTabId( spaceId, DEFAULT_TAB.id @@ -43,6 +43,7 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp const [toggleIdSelected, setToggleIdSelected] = useState(selectedTabId); const onTabChange = useCallback( (id: string) => { + scrollElement.current?.scrollTo(0, 0); setToggleIdSelected(id); setSelectedTabIdToStorage(id); }, @@ -60,7 +61,7 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp prereleaseIntegrationsEnabled: false, }); - const { showSearchTools, customCardNames, selectedCategory, selectedSubCategory } = + const { showSearchTools, customCardNames, selectedCategory, selectedSubCategory, overflow } = useTabMetaData(toggleIdSelected); const onSearchTermChanged = useCallback( @@ -122,7 +123,7 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp Date: Wed, 25 Sep 2024 16:39:44 +0100 Subject: [PATCH 09/35] cancellation link --- .../components/installation_status.test.tsx | 130 ++++++++++++++++++ .../epm/components/installation_status.tsx | 15 +- .../epm/components/package_card.test.tsx | 94 +++++++++++++ .../sections/epm/components/package_card.tsx | 10 +- .../package_list_grid/index.stories.tsx | 6 + .../detail/components/back_link.test.tsx | 13 ++ .../sections/epm/screens/detail/index.tsx | 1 + .../epm/screens/home/card_utils.test.tsx | 26 ++++ .../sections/epm/screens/home/card_utils.tsx | 30 ++-- 9 files changed, 299 insertions(+), 26 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.test.tsx diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.test.tsx new file mode 100644 index 0000000000000..2d47b44b487e0 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.test.tsx @@ -0,0 +1,130 @@ +/* + * 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 from 'react'; +import { render, screen } from '@testing-library/react'; + +import { installationStatuses } from '../../../../../../common/constants'; + +import { + InstallationStatus, + getLineClampStyles, + shouldShowInstallationStatus, +} from './installation_status'; + +// Mock useEuiTheme to return a mock theme +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + useEuiTheme: () => ({ + euiTheme: { + border: { radius: { medium: '4px' } }, + size: { s: '8px', m: '16px' }, + colors: { emptyShade: '#FFFFFF' }, + }, + }), +})); + +describe('getLineClampStyles', () => { + it('returns the correct styles when lineClamp is provided', () => { + expect(getLineClampStyles(3)).toEqual( + '-webkit-line-clamp: 3;display: -webkit-box;-webkit-box-orient: vertical;overflow: hidden;' + ); + }); + + it('returns an empty string when lineClamp is not provided', () => { + expect(getLineClampStyles()).toEqual(''); + }); +}); + +describe('shouldShowInstallationStatus', () => { + it('returns false when showInstallationStatus is false', () => { + expect( + shouldShowInstallationStatus({ + installStatus: installationStatuses.Installed, + showInstallationStatus: false, + }) + ).toEqual(false); + }); + + it('returns true when showInstallationStatus is true and installStatus is installed', () => { + expect( + shouldShowInstallationStatus({ + installStatus: installationStatuses.Installed, + showInstallationStatus: true, + }) + ).toEqual(true); + }); + + it('returns true when showInstallationStatus is true and installStatus is installFailed', () => { + expect( + shouldShowInstallationStatus({ + installStatus: installationStatuses.InstallFailed, + showInstallationStatus: true, + }) + ).toEqual(true); + }); +}); + +describe('InstallationStatus', () => { + it('renders null when showInstallationStatus is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders the Installed status correctly', () => { + render( + + ); + expect(screen.getByText('Installed')).toBeInTheDocument(); + }); + + it('renders the Install Failed status correctly', () => { + render( + + ); + expect(screen.getByText('Install Failed')).toBeInTheDocument(); + }); + + it('renders null when installStatus is null or undefined', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + + const { container: undefinedContainer } = render( + + ); + expect(undefinedContainer.firstChild).toBeNull(); + }); + + it('applies the correct styles for the component', () => { + const { getByTestId } = render( + + ); + + const spacer = getByTestId('installation-status-spacer'); + const callout = getByTestId('installation-status-callout'); + + expect(spacer).toHaveStyle('background: #FFFFFF'); + expect(callout).toHaveStyle('padding: 8px 16px'); + expect(callout).toHaveTextContent('Installed'); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx index 5036e74ae74de..a345822184051 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx @@ -10,12 +10,13 @@ import React from 'react'; import { EuiCallOut, EuiSpacer, useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; + import { installationStatuses } from '../../../../../../common/constants'; import type { EpmPackageInstallStatus } from '../../../../../../common/types'; -const installStatusMapToColor: Record< - string, - { color: 'success' | 'warning'; iconType: string; title: string } +const installStatusMapToColor: Readonly< + Record > = { installed: { color: 'success', @@ -56,7 +57,7 @@ export const InstallationStatus: React.FC = React.memo( const { euiTheme } = useEuiTheme(); return shouldShowInstallationStatus({ installStatus, showInstallationStatus }) ? (
= React.memo( `} > { return { @@ -38,6 +39,16 @@ jest.mock('../../../components', () => { }; }); +jest.mock('./installation_status', () => { + return { + shouldShowInstallationStatus: jest.fn(), + getLineClampStyles: jest.fn(), + InstallationStatus: () => { + return
; + }, + }; +}); + function cardProps(overrides: Partial = {}): PackageCardProps { return { id: 'card-1', @@ -60,8 +71,12 @@ function renderPackageCard(props: PackageCardProps) { describe('package card', () => { let mockNavigateToApp: jest.Mock; let mockNavigateToUrl: jest.Mock; + const mockGetLineClamp = getLineClampStyles as jest.Mock; + const mockShouldShowInstallationStatus = shouldShowInstallationStatus as jest.Mock; beforeEach(() => { + jest.clearAllMocks(); + mockNavigateToApp = useStartServices().application.navigateToApp as jest.Mock; mockNavigateToUrl = useStartServices().application.navigateToUrl as jest.Mock; }); @@ -136,4 +151,83 @@ describe('package card', () => { expect(!!collectionButton).toEqual(isCollectionCard); } ); + + describe('Installation status', () => { + it('should render installation status when showInstallationStatus is true', async () => { + const { + utils: { queryByTestId }, + } = renderPackageCard( + cardProps({ + showInstallationStatus: true, + }) + ); + const installationStatus = queryByTestId('installation-status'); + expect(installationStatus).toBeInTheDocument(); + }); + + it('should render max-height when maxCardHeight is provided', async () => { + const { + utils: { queryByTestId }, + } = renderPackageCard( + cardProps({ + maxCardHeight: 150, + }) + ); + const card = queryByTestId(`integration-card:card-1`); + expect(card).toHaveStyle('max-height: 150px'); + }); + + it('should render 1 line of description when descriptionLineClamp is provided and shouldShowInstallationStatus returns true', async () => { + mockShouldShowInstallationStatus.mockReturnValue(true); + renderPackageCard( + cardProps({ + showInstallationStatus: true, + installStatus: 'installed', + descriptionLineClamp: 3, + }) + ); + expect(mockShouldShowInstallationStatus).toHaveBeenCalledWith({ + installStatus: 'installed', + showInstallationStatus: true, + }); + expect(mockGetLineClamp).toHaveBeenCalledWith(1); + }); + + it('should render specific lines of description when descriptionLineClamp is provided and shouldShowInstallationStatus returns false', async () => { + mockShouldShowInstallationStatus.mockReturnValue(false); + renderPackageCard( + cardProps({ + showInstallationStatus: false, + installStatus: 'installed', + descriptionLineClamp: 3, + }) + ); + expect(mockShouldShowInstallationStatus).toHaveBeenCalledWith({ + installStatus: 'installed', + showInstallationStatus: false, + }); + expect(mockGetLineClamp).toHaveBeenCalledWith(3); + }); + + it('should not render line clamp when descriptionLineClamp is not provided', async () => { + mockShouldShowInstallationStatus.mockReturnValue(false); + renderPackageCard( + cardProps({ + showInstallationStatus: true, + installStatus: 'installed', + }) + ); + expect(mockShouldShowInstallationStatus).not.toHaveBeenCalled(); + }); + + it('should render specific lines of title when titleLineClamp is provided and shouldShowInstallationStatus returns false', async () => { + mockShouldShowInstallationStatus.mockReturnValue(false); + renderPackageCard( + cardProps({ + titleLineClamp: 1, + }) + ); + expect(mockGetLineClamp).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index 10f26bb1a917d..5f95be29c5c6d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -45,11 +45,11 @@ export type PackageCardProps = IntegrationCardItem; // Min-height is roughly 3 lines of content. // This keeps the cards from looking overly unbalanced because of content differences. -const Card = styled(EuiCard)<{ isquickstart?: boolean; fixedCardHeight?: number }>` +const Card = styled(EuiCard)<{ isquickstart?: boolean; maxCardHeight?: number }>` min-height: 127px; border-color: ${({ isquickstart }) => (isquickstart ? '#ba3d76' : null)}; - ${({ fixedCardHeight }) => - fixedCardHeight ? `max-height: ${fixedCardHeight}px; overflow: hidden;` : ''}; + ${({ maxCardHeight }) => + maxCardHeight ? `max-height: ${maxCardHeight}px; overflow: hidden;` : ''}; `; export function PackageCard({ @@ -75,7 +75,7 @@ export function PackageCard({ isCollectionCard = false, titleLineClamp, descriptionLineClamp, - fixedCardHeight, + maxCardHeight, }: PackageCardProps) { let releaseBadge: React.ReactNode | null = null; @@ -229,7 +229,7 @@ export function PackageCard({ /> } onClick={onClickProp ?? onCardClick} - fixedCardHeight={fixedCardHeight} + maxCardHeight={maxCardHeight} > {showLabels && extraLabelsBadges ? extraLabelsBadges : null} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx index 8639d18b1278b..8b5498a160e5d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.stories.tsx @@ -75,6 +75,7 @@ export const List = (props: Args) => ( icons: [], integration: 'integration', categories: ['category_two'], + installStatus: 'installed', }, { title: 'Package Two', @@ -87,6 +88,7 @@ export const List = (props: Args) => ( icons: [], integration: 'integration', categories: ['category_one'], + installStatus: 'installed', }, { title: 'Package Three', @@ -99,6 +101,7 @@ export const List = (props: Args) => ( icons: [], integration: 'integration', categories: ['web'], + installStatus: 'installed', }, { title: 'Package Four', @@ -111,6 +114,7 @@ export const List = (props: Args) => ( icons: [], integration: 'integration', categories: ['category_one'], + installStatus: 'install_failed', }, { title: 'Package Five', @@ -123,6 +127,7 @@ export const List = (props: Args) => ( icons: [], integration: 'integration', categories: ['category_two'], + installStatus: 'install_failed', }, { title: 'Package Six', @@ -135,6 +140,7 @@ export const List = (props: Args) => ( icons: [], integration: 'integration', categories: ['category_two'], + installStatus: 'install_failed', }, ]} searchTerm="" diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.test.tsx index bd1bc2ab91997..613970fc24085 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.test.tsx @@ -25,6 +25,19 @@ describe('BackLink', () => { expect(getByRole('link').getAttribute('href')).toBe(expectedUrl); }); + it('renders back to selection link when onboardingLink param is provided', () => { + const expectedUrl = '/app/experimental-onboarding'; + const queryParams = new URLSearchParams(); + queryParams.set('onboardingLink', expectedUrl); + const { getByText, getByRole } = render( + + + + ); + expect(getByText('Back to selection')).toBeInTheDocument(); + expect(getByRole('link').getAttribute('href')).toBe(expectedUrl); + }); + it('renders back to selection link with params', () => { const expectedUrl = '/app/experimental-onboarding&search=aws&category=infra'; const queryParams = new URLSearchParams(); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index 6772ff6c3aec4..a5958028dafcf 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -85,6 +85,7 @@ import { SettingsPage } from './settings'; import { CustomViewPage } from './custom'; import { DocumentationPage, hasDocumentation } from './documentation'; import { Configs } from './configs'; +import type { InstallPkgRouteOptions } from './utils/get_install_route_options'; import './index.scss'; import type { InstallPkgRouteOptions } from './utils/get_install_route_options'; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx index 40c865f8ad4d8..cb3316c47be9a 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx @@ -46,6 +46,7 @@ describe('Card utils', () => { version: '1.0.0', isUpdateAvailable: true, extraLabelsBadges: undefined, + installStatus: null, }); }); @@ -66,6 +67,7 @@ describe('Card utils', () => { release: 'preview', version: '1.0.0-preview-1', isUpdateAvailable: true, + installStatus: null, }); }); @@ -83,6 +85,30 @@ describe('Card utils', () => { release: 'preview', version: '2.0.0-preview-1', isUpdateAvailable: false, + installStatus: null, + }); + }); + + it('should return installStatus if the item is an integration', () => { + const cardItem = mapToCard({ + item: { + id: 'test', + version: '2.0.0-preview-1', + type: 'integration', + installationInfo: { + version: '1.0.0', + install_status: 'install_failed', + }, + }, + addBasePath, + getHref, + } as any); + + expect(cardItem).toMatchObject({ + release: 'ga', + version: '1.0.0', + isUpdateAvailable: true, + installStatus: 'install_failed', }); }); }); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx index dcefb21cec495..a57bdba4a9bcf 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx @@ -39,30 +39,30 @@ import { isPackageUnverified, isPackageUpdatable } from '../../../../services'; import type { PackageListItem } from '../../../../types'; export interface IntegrationCardItem { - url: string; - release?: IntegrationCardReleaseLabel; + categories: string[]; description: string; - name: string; - title: string; - version: string; + descriptionLineClamp?: number; + extraLabelsBadges?: React.ReactNode[]; + maxCardHeight?: number; + fromIntegrations?: string; icons: Array; - integration: string; id: string; - categories: string[]; - fromIntegrations?: string; + installStatus: EpmPackageInstallStatus | null | undefined; + integration: string; + isCollectionCard?: boolean; + isQuickstart?: boolean; isReauthorizationRequired?: boolean; isUnverified?: boolean; isUpdateAvailable?: boolean; - isQuickstart?: boolean; - showLabels?: boolean; - extraLabelsBadges?: React.ReactNode[]; + name: string; onCardClick?: () => void; - isCollectionCard?: boolean; + release?: IntegrationCardReleaseLabel; showInstallationStatus?: boolean; - installStatus: EpmPackageInstallStatus | null | undefined; + showLabels?: boolean; + title: string; titleLineClamp?: number; - descriptionLineClamp?: number; - fixedCardHeight?: number; + url: string; + version: string; } export const mapToCard = ({ From 94fd73f01c0a70c6ae2e94f142707970aee073e1 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 26 Sep 2024 09:28:39 +0100 Subject: [PATCH 10/35] types --- .../epm/components/package_card.stories.tsx | 1 + .../promote_featured_integrations.test.ts | 1 + .../epm/screens/home/card_utils.test.tsx | 23 +++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx index fbafda4ef220b..7e7faf095e586 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx @@ -33,6 +33,7 @@ const args: Args = { isUpdateAvailable: false, isQuickstart: false, isCollectionCard: false, + installStatus: null, }; const argTypes = { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.test.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.test.ts index 2c3e07921c6e8..11fe989aabd7a 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.test.ts +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.test.ts @@ -21,6 +21,7 @@ const mockCard = (name: string) => release: 'ga', categories: [], isUnverified: false, + installStatus: null, } as IntegrationCardItem); const intA = mockCard('A'); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx index cb3316c47be9a..adfe55a17fd9e 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx @@ -111,6 +111,29 @@ describe('Card utils', () => { installStatus: 'install_failed', }); }); + + it('should return null installStatus if the item is not an integration', () => { + const cardItem = mapToCard({ + item: { + id: 'test', + version: '2.0.0-preview-1', + type: 'xxx', + installationInfo: { + version: '1.0.0', + install_status: 'install_failed', + }, + }, + addBasePath, + getHref, + } as any); + + expect(cardItem).toMatchObject({ + release: 'ga', + version: '1.0.0', + isUpdateAvailable: true, + installStatus: null, + }); + }); }); describe('getIntegrationLabels', () => { it('should return an empty list for an integration without errors', () => { From 1800b07ffec2716a995ff8b190e92e726c3a434f Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 26 Sep 2024 13:42:09 +0100 Subject: [PATCH 11/35] add empty state styles --- .../sections/epm/components/package_list_grid/grid.tsx | 4 +++- .../sections/epm/components/package_list_grid/index.tsx | 3 +++ .../integrations/sections/epm/screens/detail/index.tsx | 1 - 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx index 1a4abf7ecd06c..4338cfb2bc918 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx @@ -29,6 +29,7 @@ interface GridColumnProps { showMissingIntegrationMessage?: boolean; showCardLabels?: boolean; scrollElementId?: string; + emptyStateStyles?: Record; } const VirtualizedRow: React.FC<{ @@ -63,6 +64,7 @@ export const GridColumn = ({ showCardLabels = false, isLoading, scrollElementId, + emptyStateStyles, }: GridColumnProps) => { const itemsSizeRefs = useRef(new Map()); const listRef = useRef(null); @@ -88,7 +90,7 @@ export const GridColumn = ({ if (!list.length) { return ( - +

diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx index 52be57a5aac77..829cc23a0d646 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx @@ -51,6 +51,7 @@ const StickySidebar = styled(EuiFlexItem)` export interface PackageListGridProps { isLoading?: boolean; controls?: ReactNode | ReactNode[]; + emptyStateStyles?: Record; list: IntegrationCardItem[]; searchTerm: string; setSearchTerm: (search: string) => void; @@ -76,6 +77,7 @@ export interface PackageListGridProps { export const PackageListGrid: FunctionComponent = ({ isLoading, controls, + emptyStateStyles, title, list, searchTerm, @@ -274,6 +276,7 @@ export const PackageListGrid: FunctionComponent = ({ {spacer && } Date: Thu, 26 Sep 2024 13:47:04 +0100 Subject: [PATCH 12/35] update complete badge --- .../cards/integrations/available_packages.tsx | 84 +++++++++---------- .../cards/integrations/const.ts | 1 + .../cards/integrations/integrations_card.tsx | 3 +- .../integrations_check_complete.ts | 6 +- .../integrations_header_badges.tsx | 37 -------- .../cards/integrations/package_list_grid.tsx | 5 +- .../cards/integrations/utils.ts | 4 +- .../hooks/use_completed_cards.ts | 14 ++-- .../public/onboarding/types.ts | 2 - 9 files changed, 55 insertions(+), 101 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_header_badges.tsx diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx index 26f1fcf04f895..84ccedd0d2525 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx @@ -15,54 +15,52 @@ import { fetchAvailablePackagesHook } from './utils'; import { PackageListGrid } from './package_list_grid'; import { LOADING_SKELETON_HEIGHT } from './const'; -export const AvailablePackages = React.memo( - ({ setComplete }: { setComplete: (complete: boolean) => void }) => { - const ref = useRef(null); +export const AvailablePackages = React.memo(() => { + const ref = useRef(null); - const { - error: errorLoading, - retry: retryAsyncLoad, - loading: asyncLoading, - } = useAsyncRetry(async () => { - ref.current = await fetchAvailablePackagesHook(); - }); + const { + error: errorLoading, + retry: retryAsyncLoad, + loading: asyncLoading, + } = useAsyncRetry(async () => { + ref.current = await fetchAvailablePackagesHook(); + }); - if (errorLoading) - return ( - +

+ +

+ { + if (!asyncLoading) retryAsyncLoad(); + }} > -

- -

- { - if (!asyncLoading) retryAsyncLoad(); - }} - > - - - - ); + +
+ + ); - if (asyncLoading || ref.current === null) - return ; + if (asyncLoading || ref.current === null) + return ; - return ; - } -); + return ; +}); AvailablePackages.displayName = 'AvailablePackages'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts index 58e4b09609470..15fd950a4cd12 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts @@ -14,6 +14,7 @@ export const SEARCH_FILTER_CATEGORIES: CategoryFacet[] = []; export const SCROLL_ELEMENT_ID = 'integrations-scroll-container'; export const WITH_SEARCH_BOX_HEIGHT = '517px'; export const WITHOUT_SEARCH_BOX_HEIGHT = '462px'; +export const MAX_CARD_HEIGHT = 127; // px export const LOADING_SKELETON_HEIGHT = 10; // 10 lines of text export const INTEGRATION_TABS: Tab[] = [ { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index fe88678ee3c47..ce9ce2318c86b 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -16,7 +16,6 @@ import { LinkAnchor } from '../../../../../common/components/links'; import { useKibana } from '../../../../../common/lib/kibana'; export const IntegrationsCard: OnboardingCardComponent = ({ - setComplete, checkCompleteMetadata, // this is undefined before the first checkComplete call finishes }) => { const integrationsInstalled: number | undefined = checkCompleteMetadata?.integrationsInstalled as @@ -77,7 +76,7 @@ export const IntegrationsCard: OnboardingCardComponent = ({ )} - + ); }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts index 91d10c7883cc0..a56def194c5bd 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts @@ -60,11 +60,7 @@ export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async return { isComplete, - completeBadgeText: getCompleteBadgeWithTooltip({ - agentStillRequired, - navigateToApp, - completeBadgeText, - }), + completeBadgeText, metadata: { integrationsInstalled: installed.length, agentStillRequired, diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_header_badges.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_header_badges.tsx deleted file mode 100644 index be65c5326127f..0000000000000 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_header_badges.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 from 'react'; -import { EuiToolTip } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import type { NavigateToAppOptions } from '@kbn/core/public'; - -export const getCompleteBadgeWithTooltip = ({ - agentStillRequired, - navigateToApp, - completeBadgeText, -}: { - agentStillRequired: boolean; - navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise; - completeBadgeText: string; -}) => { - return agentStillRequired ? ( - - } - > - <>{completeBadgeText} - - ) : ( - completeBadgeText - ); -}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx index 71622c467611b..ab3446eff90a5 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -32,6 +32,8 @@ import { interface WrapperProps { useAvailablePackages: AvailablePackagesHookType; } + +const emptyStateStyles = { paddingTop: '16px' }; export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProps) => { const { spaceId } = useOnboardingContext(); const scrollElement = useRef(null); @@ -76,7 +78,7 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp setCategory(selectedCategory); setSelectedSubCategory(selectedSubCategory); if (!showSearchTools) { - // If search tools are not shown, clear the search term to avoid unexpected filtering + // If search box are not shown, clear the search term to avoid unexpected filtering onSearchTermChanged(''); } }, [ @@ -132,6 +134,7 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp }> ({ default: await import('@kbn/fleet-plugin/public') @@ -89,7 +89,7 @@ const addSecuritySpecificProps = ({ ...card, titleLineClamp: 1, descriptionLineClamp: 3, - fixedCardHeight: 127, + maxCardHeight: MAX_CARD_HEIGHT, showInstallationStatus: true, url: card.url.indexOf(APP_INTEGRATIONS_PATH) >= 0 && onboardingLink diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts index dcd407e16953e..926203248a61e 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts @@ -34,11 +34,7 @@ export type CardCheckCompleteResult = Partial { const { spaceId, reportCardComplete } = useOnboardingContext(); - const { - http, - data, - application: { navigateToApp }, - } = useKibana().services; + const { http, data } = useKibana().services; // Use stored state to keep localStorage in sync, and a local state to avoid unnecessary re-renders. const [storedCompleteCardIds, setStoredCompleteCardIds] = useStoredCompletedCardIds(spaceId); @@ -117,22 +113,22 @@ export const useCompletedCards = (cardsGroupConfig: OnboardingGroupConfig[]) => const cardConfig = cardsWithAutoCheck.find(({ id }) => id === cardId); if (cardConfig) { - cardConfig.checkComplete?.({ http, data, navigateToApp }).then((checkCompleteResult) => { + cardConfig.checkComplete?.({ http, data }).then((checkCompleteResult) => { processCardCheckCompleteResult(cardId, checkCompleteResult); }); } }, - [cardsWithAutoCheck, http, data, navigateToApp, processCardCheckCompleteResult] + [cardsWithAutoCheck, http, data, processCardCheckCompleteResult] ); // Initial auto-check for all cards, it should run only once, after cardsGroupConfig is properly populated useEffect(() => { cardsWithAutoCheck.map((card) => - card.checkComplete?.({ http, data, navigateToApp }).then((checkCompleteResult) => { + card.checkComplete?.({ http, data }).then((checkCompleteResult) => { processCardCheckCompleteResult(card.id, checkCompleteResult); }) ); - }, [cardsWithAutoCheck, http, data, navigateToApp, processCardCheckCompleteResult]); + }, [cardsWithAutoCheck, http, data, processCardCheckCompleteResult]); return { isCardComplete, diff --git a/x-pack/plugins/security_solution/public/onboarding/types.ts b/x-pack/plugins/security_solution/public/onboarding/types.ts index eaec4b9a1bec6..6c8c14fadb3c3 100644 --- a/x-pack/plugins/security_solution/public/onboarding/types.ts +++ b/x-pack/plugins/security_solution/public/onboarding/types.ts @@ -68,11 +68,9 @@ export type OnboardingCardComponent = React.ComponentType<{ export type OnboardingCardCheckComplete = ({ http, data, - navigateToApp, }: { http: HttpSetup; data: DataPublicPluginStart; - navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise; }) => Promise; export interface OnboardingCardConfig { From b6857c322c91b51ef8cf1ea21d777da2a2bae80c Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Fri, 27 Sep 2024 16:48:00 +0100 Subject: [PATCH 13/35] unit tests --- .../integrations/available_packages.test.tsx | 75 ++++++++ .../cards/integrations/available_packages.tsx | 50 +++--- .../cards/integrations/const.ts | 21 ++- .../cards/integrations/hooks.test.ts | 165 ++++++++++++++++++ .../integrations/integrations_card.test.tsx | 100 +++++++++++ .../cards/integrations/integrations_card.tsx | 11 +- .../integrations_check_complete.test.ts | 111 ++++++++++++ .../integrations_check_complete.ts | 16 +- .../integrations/package_list_grid.test.tsx | 138 +++++++++++++++ .../cards/integrations/package_list_grid.tsx | 14 +- .../cards/integrations/types.ts | 2 +- .../cards/integrations/utils.test.ts | 102 +++++++++++ .../cards/integrations/utils.ts | 12 +- 13 files changed, 768 insertions(+), 49 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.test.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.test.tsx new file mode 100644 index 0000000000000..35692a87cf4a4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.test.tsx @@ -0,0 +1,75 @@ +/* + * 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 from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { AvailablePackages } from './available_packages'; +import { useAsyncRetry } from 'react-use'; +import { fetchAvailablePackagesHook } from './utils'; +import { TestProviders } from '../../../../../common/mock/test_providers'; + +jest.mock('react-use/lib/useAsyncRetry'); +jest.mock('./utils', () => ({ + fetchAvailablePackagesHook: jest.fn(), +})); +jest.mock('./package_list_grid', () => ({ + PackageListGrid: jest.fn(() =>
), +})); + +describe('AvailablePackages', () => { + const mockRetry = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows loading skeleton while fetching data', () => { + (useAsyncRetry as jest.Mock).mockReturnValue({ + error: null, + retry: mockRetry, + loading: true, + }); + + const { getByTestId } = render(, { wrapper: TestProviders }); + expect(getByTestId('loadingPackages')).toBeInTheDocument(); + }); + + it('shows error callout when there is an error loading data', () => { + (useAsyncRetry as jest.Mock).mockReturnValue({ + error: new Error('Loading error'), + retry: mockRetry, + loading: false, + }); + + const { getByTestId } = render(, { wrapper: TestProviders }); + + const retryButton = getByTestId('retryButton'); + expect(retryButton).toBeInTheDocument(); + + fireEvent.click(retryButton); + expect(mockRetry).toHaveBeenCalled(); + }); + + it('renders PackageListGrid when data is loaded successfully', async () => { + const mockAvailablePackages = jest.fn(); + (fetchAvailablePackagesHook as jest.Mock).mockResolvedValue(mockAvailablePackages); + + (useAsyncRetry as jest.Mock).mockImplementation(async (cb) => { + await cb(); + return { + error: null, + retry: mockRetry, + loading: false, + }; + }); + + const { getByTestId } = render(, { wrapper: TestProviders }); + + await waitFor(() => { + expect(getByTestId('package-list-grid')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx index 84ccedd0d2525..79c3fbed2fedf 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useRef } from 'react'; +import React, { useCallback, useState } from 'react'; import useAsyncRetry from 'react-use/lib/useAsyncRetry'; import type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public'; @@ -16,17 +16,25 @@ import { PackageListGrid } from './package_list_grid'; import { LOADING_SKELETON_HEIGHT } from './const'; export const AvailablePackages = React.memo(() => { - const ref = useRef(null); + const [fetchAvailablePackages, setFetchAvailablePackages] = useState(); - const { - error: errorLoading, - retry: retryAsyncLoad, - loading: asyncLoading, - } = useAsyncRetry(async () => { - ref.current = await fetchAvailablePackagesHook(); + const { error, retry, loading } = useAsyncRetry(async () => { + if (fetchAvailablePackages) { + return; + } + const loadedHook = await fetchAvailablePackagesHook(); + setFetchAvailablePackages(() => { + return loadedHook; + }); }); - if (errorLoading) + const onRetry = useCallback(() => { + if (!loading) { + retry(); + } + }, [loading, retry]); + + if (error) { return ( { defaultMessage="Some required elements failed to load." />

- { - if (!asyncLoading) retryAsyncLoad(); - }} - > + {
); - - if (asyncLoading || ref.current === null) - return ; - - return ; + } + if (loading || !fetchAvailablePackages) { + return ( + + ); + } + return ; }); AvailablePackages.displayName = 'AvailablePackages'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts index 15fd950a4cd12..5ab98a111d43a 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts @@ -15,50 +15,53 @@ export const SCROLL_ELEMENT_ID = 'integrations-scroll-container'; export const WITH_SEARCH_BOX_HEIGHT = '517px'; export const WITHOUT_SEARCH_BOX_HEIGHT = '462px'; export const MAX_CARD_HEIGHT = 127; // px +export const CARD_TITLE_LINE_CLAMP = 1; // 1 line of text +export const CARD_DESCRIPTION_LINE_CLAMP = 3; // 3 lines of text export const LOADING_SKELETON_HEIGHT = 10; // 10 lines of text +export const AGENT_INDEX = `logs-elastic_agent*`; export const INTEGRATION_TABS: Tab[] = [ { - id: 'recommended', - label: 'Recommended', category: 'security', iconType: 'starFilled', - showSearchTools: false, + id: 'recommended', + label: 'Recommended', overflow: 'hidden', + showSearchTools: false, }, { + category: 'security', id: 'network', label: 'Network', - category: 'security', subCategory: 'network_security', }, { + category: 'security', id: 'user', label: 'User', - category: 'security', subCategory: 'iam', }, { + category: 'security', id: 'endpoint', label: 'Endpoint', - category: 'security', subCategory: 'edr_xdr', }, { + category: 'security', id: 'cloud', label: 'Cloud', - category: 'security', subCategory: 'cloudsecurity_cdr', }, { + category: 'security', id: 'threatIntel', label: 'Threat Intel', - category: 'security', subCategory: 'threat_intel', }, { + category: '', id: 'all', label: 'All', - category: '', }, ]; export const DEFAULT_TAB = INTEGRATION_TABS[0]; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts new file mode 100644 index 0000000000000..8e4bb511177bb --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts @@ -0,0 +1,165 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useIntegrationCardList, useTabMetaData } from './hooks'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { getFilteredCards } from './utils'; + +jest.mock('../../../../../common/lib/kibana', () => ({ + useKibana: jest.fn(), +})); + +jest.mock('./utils', () => ({ + getFilteredCards: jest.fn(), +})); + +describe('useIntegrationCardList', () => { + const mockBasePath = '/mock/base/path'; + const mockIntegrationsList = [ + { + id: 'security', + name: 'Security Integration', + description: 'Integration for security monitoring', + categories: ['security'], + icons: [{ src: 'icon_url', type: 'image' }], + installStatus: null, + integration: 'security', + title: 'Security Integration', + url: '/app/integrations/security', + version: '1.0.0', + }, + ]; + + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + http: { basePath: { get: () => mockBasePath } }, + }, + }); + }); + + it('returns filtered integration cards when customCardNames are not provided', () => { + const mockFilteredCards = { + featuredCards: {}, + integrationCards: mockIntegrationsList, + }; + (getFilteredCards as jest.Mock).mockReturnValue(mockFilteredCards); + + const { result } = renderHook(() => + useIntegrationCardList({ + integrationsList: mockIntegrationsList, + }) + ); + + expect(getFilteredCards).toHaveBeenCalledWith(mockIntegrationsList, undefined, mockBasePath); + expect(result.current).toEqual(mockFilteredCards.integrationCards); + }); + + it('returns featured cards when customCardNames are provided', () => { + const customCardNames = ['Security Integration']; + const mockFilteredCards = { + featuredCards: { + 'Security Integration': mockIntegrationsList[0], + }, + integrationCards: mockIntegrationsList, + }; + (getFilteredCards as jest.Mock).mockReturnValue(mockFilteredCards); + + const { result } = renderHook(() => + useIntegrationCardList({ + integrationsList: mockIntegrationsList, + customCardNames, + }) + ); + + expect(getFilteredCards).toHaveBeenCalledWith( + mockIntegrationsList, + customCardNames, + mockBasePath + ); + expect(result.current).toEqual([mockFilteredCards.featuredCards['Security Integration']]); + }); +}); + +describe.each([ + { + id: 'recommended', + expected: { + customCardNames: undefined, + showSearchTools: false, + selectedCategory: 'security', + selectedSubCategory: undefined, + overflow: 'hidden', + }, + }, + { + id: 'network', + expected: { + customCardNames: undefined, + showSearchTools: true, + selectedCategory: 'security', + selectedSubCategory: 'network_security', + overflow: 'scroll', + }, + }, + { + id: 'user', + expected: { + customCardNames: undefined, + showSearchTools: true, + selectedCategory: 'security', + selectedSubCategory: 'iam', + overflow: 'scroll', + }, + }, + { + id: 'endpoint', + expected: { + customCardNames: undefined, + showSearchTools: true, + selectedCategory: 'security', + selectedSubCategory: 'edr_xdr', + overflow: 'scroll', + }, + }, + { + id: 'cloud', + expected: { + customCardNames: undefined, + showSearchTools: true, + selectedCategory: 'security', + selectedSubCategory: 'cloudsecurity_cdr', + overflow: 'scroll', + }, + }, + { + id: 'threatIntel', + expected: { + customCardNames: undefined, + showSearchTools: true, + selectedCategory: 'security', + selectedSubCategory: 'threat_intel', + overflow: 'scroll', + }, + }, + { + id: 'all', + expected: { + customCardNames: undefined, + showSearchTools: true, + selectedCategory: '', + selectedSubCategory: undefined, + overflow: 'scroll', + }, + }, +])('useTabMetaData', ({ id, expected }) => { + it(`returns correct metadata for the ${id} tab`, () => { + const { result } = renderHook(() => useTabMetaData(id)); + + expect(result.current).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx new file mode 100644 index 0000000000000..caba01dba55fb --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx @@ -0,0 +1,100 @@ +/* + * 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 from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntegrationsCard } from './integrations_card'; +import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; +import { useKibana } from '../../../../../common/lib/kibana'; + +jest.mock('../../../../../common/hooks/use_add_integrations_url'); +jest.mock('../../../../../common/lib/kibana'); +jest.mock('./available_packages', () => ({ + AvailablePackages: () =>
, +})); + +const mockNavigateToApp = jest.fn(); +(useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + navigateToApp: mockNavigateToApp, + }, + }, +}); + +const mockOnAddIntegrationsUrl = jest.fn(); +(useAddIntegrationsUrl as jest.Mock).mockReturnValue({ + href: '/integrations', + onClick: mockOnAddIntegrationsUrl, +}); + +const props = { + setComplete: jest.fn(), + isCardComplete: jest.fn(), + setExpandedCardId: jest.fn(), +}; + +describe('IntegrationsCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the callout and available packages when integrationsInstalled is present', () => { + const mockMetadata = { + integrationsInstalled: 3, + agentStillRequired: false, + }; + + const { getByTestId } = render( + + ); + + expect(getByTestId('integrationsCompleteText')).toBeInTheDocument(); + expect(getByTestId('manageIntegrationsLink')).toBeInTheDocument(); + }); + + it('renders the warning callout when an agent is still required', () => { + const mockMetadata = { + integrationsInstalled: 2, + agentStillRequired: true, + }; + + const { getByTestId } = render( + + ); + + expect(getByTestId('agentRequiredText')).toBeInTheDocument(); + expect(getByTestId('agentLink')).toBeInTheDocument(); + }); + + it('handles navigation to the Fleet app when Add Agent is clicked', () => { + const mockMetadata = { + integrationsInstalled: 1, + agentStillRequired: true, + }; + + const { getByTestId } = render( + + ); + + fireEvent.click(getByTestId('agentLink')); + expect(mockNavigateToApp).toHaveBeenCalledWith('fleet', { path: '/agents' }); + }); + + it('handles clicking on the Manage integrations link', () => { + const mockMetadata = { + integrationsInstalled: 3, + agentStillRequired: false, + }; + + const { getByTestId } = render( + + ); + + fireEvent.click(getByTestId('manageIntegrationsLink')); + expect(mockOnAddIntegrationsUrl).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index ce9ce2318c86b..03b1f9898bcdc 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -42,25 +42,31 @@ export const IntegrationsCard: OnboardingCardComponent = ({ values={{ desc1: checkCompleteMetadata?.agentStillRequired ? ( ) : ( ), desc2: checkCompleteMetadata?.agentStillRequired ? ( - + ) : ( - + } /> - )} diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts new file mode 100644 index 0000000000000..856e85bdd0408 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { checkIntegrationsCardComplete } from './integrations_check_complete'; +import { installationStatuses } from '@kbn/fleet-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { HttpSetup } from '@kbn/core/public'; +import { lastValueFrom } from 'rxjs'; + +jest.mock('rxjs', () => ({ + ...jest.requireActual('rxjs'), + lastValueFrom: jest.fn(), +})); + +describe('checkIntegrationsCardComplete', () => { + const mockHttpGet: jest.Mock = jest.fn(); + const mockSearch: jest.Mock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockDataPlugin = { + search: { + search: mockSearch, + }, + } as unknown as DataPublicPluginStart; + + const httpSetup = { + get: mockHttpGet, + } as unknown as HttpSetup; + + it('returns isComplete as false when no packages are installed', async () => { + mockHttpGet.mockResolvedValue({ + items: [], + }); + + (lastValueFrom as jest.Mock).mockResolvedValue({ + rawResponse: { + hits: { total: 0 }, + }, + }); + + const result = await checkIntegrationsCardComplete({ + data: mockDataPlugin, + http: httpSetup, + }); + + expect(result).toEqual({ + isComplete: false, + }); + }); + + it('returns isComplete as true when packages are installed but no agent data is available', async () => { + mockHttpGet.mockResolvedValue({ + items: [{ status: installationStatuses.Installed }], + }); + + (lastValueFrom as jest.Mock).mockResolvedValue({ + rawResponse: { + hits: { total: 0 }, + }, + }); + + const result = await checkIntegrationsCardComplete({ + data: mockDataPlugin, + http: httpSetup, + }); + + expect(result).toEqual({ + isComplete: true, + completeBadgeText: '1 integration added', + metadata: { + integrationsInstalled: 1, + agentStillRequired: true, + }, + }); + }); + + it('returns isComplete as true and agentStillRequired as false when both packages and agent data are available', async () => { + mockHttpGet.mockResolvedValue({ + items: [ + { status: installationStatuses.Installed }, + { status: installationStatuses.InstallFailed }, + ], + }); + + (lastValueFrom as jest.Mock).mockResolvedValue({ + rawResponse: { + hits: { total: 1 }, + }, + }); + + const result = await checkIntegrationsCardComplete({ + data: mockDataPlugin, + http: httpSetup, + }); + + expect(result).toEqual({ + isComplete: true, + completeBadgeText: '2 integrations added', + metadata: { + integrationsInstalled: 2, + agentStillRequired: false, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts index a56def194c5bd..dced47a256b15 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts @@ -7,29 +7,27 @@ import type { GetPackagesResponse } from '@kbn/fleet-plugin/public'; import { EPM_PACKAGES_MANY, installationStatuses } from '@kbn/fleet-plugin/public'; -import type { HttpSetup, NavigateToAppOptions } from '@kbn/core/public'; +import type { HttpSetup } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { lastValueFrom } from 'rxjs'; import type { OnboardingCardCheckComplete } from '../../../../types'; -import { getCompleteBadgeWithTooltip } from './integrations_header_badges'; +import { AGENT_INDEX } from './const'; export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async ({ data, http, - navigateToApp, }: { data: DataPublicPluginStart; http: HttpSetup; - navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise; }) => { const packageData = await http.get(EPM_PACKAGES_MANY, { version: '2023-10-31', }); - const agentsAvailable = await lastValueFrom( + const agentsData = await lastValueFrom( data.search.search({ - params: { index: `logs-elastic_agent*`, body: { size: 1 } }, + params: { index: AGENT_INDEX, body: { size: 1 } }, }) ); @@ -39,10 +37,8 @@ export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async pkg.status === installationStatuses.InstallFailed ); const isComplete = installed && installed.length > 0; - const agentStillRequired = - isComplete && - agentsAvailable?.rawResponse?.hits?.total != null && - agentsAvailable?.rawResponse?.hits?.total === 0; + const agentsDataAvailable = !!agentsData?.rawResponse?.hits?.total; + const agentStillRequired = isComplete && !agentsDataAvailable; const completeBadgeText = i18n.translate( 'xpack.securitySolution.onboarding.integrationsCard.badge.completeText', diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx new file mode 100644 index 0000000000000..26ca7507554b8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx @@ -0,0 +1,138 @@ +/* + * 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 from 'react'; +import { render, fireEvent, waitFor, act } from '@testing-library/react'; +import { PackageListGrid } from './package_list_grid'; +import { + useStoredIntegrationSearchTerm, + useStoredIntegrationTabId, +} from '../../../../hooks/use_stored_state'; +import { useIntegrationCardList, useTabMetaData } from './hooks'; +import { PackageList } from './utils'; +import { DEFAULT_TAB } from './const'; + +jest.mock('../../../onboarding_context'); +jest.mock('../../../../hooks/use_stored_state'); +jest.mock('./hooks'); +jest.mock('./utils', () => ({ + PackageList: jest.fn(() =>
), +})); + +describe('PackageListGrid', () => { + const mockUseAvailablePackages = jest.fn(); + const mockPackageList = PackageList as unknown as jest.Mock; + const mockSetTabId = jest.fn(); + const mockSetCategory = jest.fn(); + const mockSetSelectedSubCategory = jest.fn(); + const mockSetSearchTerm = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useStoredIntegrationTabId as jest.Mock).mockReturnValue([DEFAULT_TAB.id, jest.fn()]); + (useStoredIntegrationSearchTerm as jest.Mock).mockReturnValue(['', jest.fn()]); + (useTabMetaData as jest.Mock).mockReturnValue({ + showSearchTools: true, + customCardNames: {}, + selectedCategory: 'security', + selectedSubCategory: 'network', + }); + (useIntegrationCardList as jest.Mock).mockReturnValue([]); + }); + + it('renders loading skeleton when data is loading', () => { + mockUseAvailablePackages.mockReturnValue({ + isLoading: true, + filteredCards: [], + setCategory: mockSetCategory, + setSelectedSubCategory: mockSetSelectedSubCategory, + setSearchTerm: mockSetSearchTerm, + }); + + const { getByTestId } = render( + + ); + + expect(getByTestId('loadingPackages')).toBeInTheDocument(); + }); + + it('renders the package list when data is available', () => { + mockUseAvailablePackages.mockReturnValue({ + isLoading: false, + filteredCards: [{ id: 'card1', name: 'Card 1' }], + setCategory: mockSetCategory, + setSelectedSubCategory: mockSetSelectedSubCategory, + setSearchTerm: mockSetSearchTerm, + }); + + const { getByTestId } = render( + + ); + + expect(getByTestId('packageList')).toBeInTheDocument(); + }); + + it('saves the selected tab to storage', () => { + (useStoredIntegrationTabId as jest.Mock).mockReturnValue(['recommended', mockSetTabId]); + + mockUseAvailablePackages.mockReturnValue({ + isLoading: false, + filteredCards: [], + setCategory: mockSetCategory, + setSelectedSubCategory: mockSetSelectedSubCategory, + setSearchTerm: mockSetSearchTerm, + }); + + const { getByTestId } = render( + + ); + + const tabButton = getByTestId('user'); + + fireEvent.click(tabButton); + expect(mockSetTabId).toHaveBeenCalledWith('user'); + }); + + it('renders no search tools when showSearchTools is false', () => { + (useTabMetaData as jest.Mock).mockReturnValue({ + showSearchTools: false, + customCardNames: {}, + selectedCategory: 'category1', + selectedSubCategory: 'subcategory1', + overflow: 'auto', + }); + + mockUseAvailablePackages.mockReturnValue({ + isLoading: false, + filteredCards: [], + setCategory: mockSetCategory, + setSelectedSubCategory: mockSetSelectedSubCategory, + setSearchTerm: mockSetSearchTerm, + }); + + render(); + + expect(mockPackageList.mock.calls[0][0].showSearchTools).toEqual(false); + }); + + it('updates the search term when the search input changes', async () => { + const mockSetSearchTermToStorage = jest.fn(); + (useStoredIntegrationSearchTerm as jest.Mock).mockReturnValue(['', mockSetSearchTermToStorage]); + + mockUseAvailablePackages.mockReturnValue({ + isLoading: false, + filteredCards: [], + setCategory: mockSetCategory, + setSelectedSubCategory: mockSetSelectedSubCategory, + setSearchTerm: mockSetSearchTerm, + searchTerm: 'new search term', + }); + + render(); + + expect(mockPackageList.mock.calls[0][0].searchTerm).toEqual('new search term'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx index ab3446eff90a5..0a0305b58e45c 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -45,7 +45,7 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp const [toggleIdSelected, setToggleIdSelected] = useState(selectedTabId); const onTabChange = useCallback( (id: string) => { - scrollElement.current?.scrollTo(0, 0); + scrollElement.current?.scrollTo?.(0, 0); setToggleIdSelected(id); setSelectedTabIdToStorage(id); }, @@ -81,6 +81,10 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp // If search box are not shown, clear the search term to avoid unexpected filtering onSearchTermChanged(''); } + + if (showSearchTools && searchTermFromStorage) { + setSearchTerm(searchTermFromStorage); + } }, [ onSearchTermChanged, searchTermFromStorage, @@ -99,7 +103,13 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp }); if (isLoading) { - return ; + return ( + + ); } return ( diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts index dda937c978a1e..40c23d7628f7d 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts @@ -10,7 +10,7 @@ export interface Tab { iconType?: string; id: string; label: string; + overflow?: 'hidden' | 'scroll'; showSearchTools?: boolean; subCategory?: string; - overflow?: 'hidden' | 'scroll'; } diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts new file mode 100644 index 0000000000000..7c829a54b2840 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts @@ -0,0 +1,102 @@ +/* + * 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 type { IntegrationCardItem } from '@kbn/fleet-plugin/public'; +import { extractFeaturedCards, getFilteredCards } from './utils'; // Update the path accordingly + +const maxCardHeight = 127; +const cardTitleLineClamp = 1; +const cardDescriptionLineClamp = 3; +const expectedUrl = `/app/integrations?onboardingLink=${encodeURIComponent( + '/app/security/get_started' +)}&onboardingAppId=securitySolutionUI`; +const mockIntegrationCardItem = { + categories: ['security'], + description: 'Security integration for monitoring.', + icons: [ + { + src: 'icon_url', + type: 'image', + }, + ], + id: 'security-integration', + installStatus: null, + integration: 'security', + name: 'Security Integration', + title: 'Security Integration', + url: '/app/integrations', + version: '1.0.0', +}; +const mockIntegrationCardItems: IntegrationCardItem[] = [mockIntegrationCardItem]; + +describe('extractFeaturedCards', () => { + it('returns an empty object when no featuredCardNames are provided', () => { + const result = extractFeaturedCards(mockIntegrationCardItems, []); + expect(result).toEqual({}); + }); + + it('extracts featured cards when featuredCardNames are provided', () => { + const featuredNames = ['Security Integration']; + const result = extractFeaturedCards(mockIntegrationCardItems, featuredNames); + + expect(result).toEqual({ + 'Security Integration': mockIntegrationCardItem, + }); + }); + + it('returns an empty object when no matching featured cards are found', () => { + const result = extractFeaturedCards(mockIntegrationCardItems, ['NonExistentCard']); + expect(result).toEqual({}); + }); +}); + +describe('getFilteredCards', () => { + it('returns integration cards without featured cards when no custom cards are provided', () => { + const result = getFilteredCards(mockIntegrationCardItems); + + expect(result).toEqual({ + featuredCards: {}, + integrationCards: [ + { + ...mockIntegrationCardItems[0], + titleLineClamp: cardTitleLineClamp, + descriptionLineClamp: cardDescriptionLineClamp, + maxCardHeight, + showInstallationStatus: true, + url: expectedUrl, + }, + ], + }); + }); + + it('returns both featured cards and integration cards when custom cards are provided', () => { + const customCards = ['Security Integration']; + const result = getFilteredCards(mockIntegrationCardItems, customCards); + + expect(result).toEqual({ + featuredCards: { + 'Security Integration': { + ...mockIntegrationCardItems[0], + titleLineClamp: cardTitleLineClamp, + descriptionLineClamp: cardDescriptionLineClamp, + maxCardHeight, + showInstallationStatus: true, + url: expectedUrl, + }, + }, + integrationCards: [ + { + ...mockIntegrationCardItems[0], + titleLineClamp: cardTitleLineClamp, + descriptionLineClamp: cardDescriptionLineClamp, + maxCardHeight, + showInstallationStatus: true, + url: expectedUrl, + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts index 58b8e93085218..37734ada7b312 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts @@ -15,7 +15,13 @@ import { APP_UI_ID, ONBOARDING_PATH, } from '../../../../../../common/constants'; -import { MAX_CARD_HEIGHT, ONBOARDING_APP_ID, ONBOARDING_LINK } from './const'; +import { + CARD_DESCRIPTION_LINE_CLAMP, + CARD_TITLE_LINE_CLAMP, + MAX_CARD_HEIGHT, + ONBOARDING_APP_ID, + ONBOARDING_LINK, +} from './const'; export const PackageList = lazy(async () => ({ default: await import('@kbn/fleet-plugin/public') @@ -87,8 +93,8 @@ const addSecuritySpecificProps = ({ const onboardingLink = getOnboardingPath(basePath); return { ...card, - titleLineClamp: 1, - descriptionLineClamp: 3, + titleLineClamp: CARD_TITLE_LINE_CLAMP, + descriptionLineClamp: CARD_DESCRIPTION_LINE_CLAMP, maxCardHeight: MAX_CARD_HEIGHT, showInstallationStatus: true, url: From ea3266fc7c1354266c26b7e4c4ca356626f091fd Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Fri, 27 Sep 2024 16:59:53 +0100 Subject: [PATCH 14/35] update installStatus in IntegrationCardItem --- .../sections/epm/components/package_card.stories.tsx | 1 - .../sections/epm/components/package_card.tsx | 8 ++++---- .../sections/epm/screens/home/card_utils.test.tsx | 6 +----- .../sections/epm/screens/home/card_utils.tsx | 11 ++++++++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx index 7e7faf095e586..fbafda4ef220b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.stories.tsx @@ -33,7 +33,6 @@ const args: Args = { isUpdateAvailable: false, isQuickstart: false, isCollectionCard: false, - installStatus: null, }; const argTypes = { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index 5f95be29c5c6d..31213e5f9554a 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -45,11 +45,11 @@ export type PackageCardProps = IntegrationCardItem; // Min-height is roughly 3 lines of content. // This keeps the cards from looking overly unbalanced because of content differences. -const Card = styled(EuiCard)<{ isquickstart?: boolean; maxCardHeight?: number }>` +const Card = styled(EuiCard)<{ isquickstart?: boolean; $maxCardHeight?: number }>` min-height: 127px; border-color: ${({ isquickstart }) => (isquickstart ? '#ba3d76' : null)}; - ${({ maxCardHeight }) => - maxCardHeight ? `max-height: ${maxCardHeight}px; overflow: hidden;` : ''}; + ${({ $maxCardHeight }) => + $maxCardHeight ? `max-height: ${$maxCardHeight}px; overflow: hidden;` : ''}; `; export function PackageCard({ @@ -229,7 +229,7 @@ export function PackageCard({ /> } onClick={onClickProp ?? onCardClick} - maxCardHeight={maxCardHeight} + $maxCardHeight={maxCardHeight} > {showLabels && extraLabelsBadges ? extraLabelsBadges : null} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx index adfe55a17fd9e..422ed4c3a01e7 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.test.tsx @@ -46,7 +46,6 @@ describe('Card utils', () => { version: '1.0.0', isUpdateAvailable: true, extraLabelsBadges: undefined, - installStatus: null, }); }); @@ -67,7 +66,6 @@ describe('Card utils', () => { release: 'preview', version: '1.0.0-preview-1', isUpdateAvailable: true, - installStatus: null, }); }); @@ -85,7 +83,6 @@ describe('Card utils', () => { release: 'preview', version: '2.0.0-preview-1', isUpdateAvailable: false, - installStatus: null, }); }); @@ -112,7 +109,7 @@ describe('Card utils', () => { }); }); - it('should return null installStatus if the item is not an integration', () => { + it('should not return installStatus if the item is not an integration', () => { const cardItem = mapToCard({ item: { id: 'test', @@ -131,7 +128,6 @@ describe('Card utils', () => { release: 'ga', version: '1.0.0', isUpdateAvailable: true, - installStatus: null, }); }); }); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx index a57bdba4a9bcf..0aeb49ac7f1df 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx @@ -47,7 +47,7 @@ export interface IntegrationCardItem { fromIntegrations?: string; icons: Array; id: string; - installStatus: EpmPackageInstallStatus | null | undefined; + installStatus?: EpmPackageInstallStatus; integration: string; isCollectionCard?: boolean; isQuickstart?: boolean; @@ -116,7 +116,7 @@ export const mapToCard = ({ extraLabelsBadges = getIntegrationLabels(item); } - return { + const cardResult: IntegrationCardItem = { id: `${item.type === 'ui_link' ? 'ui_link' : 'epr'}:${item.id}`, description: item.description, icons: !item.icons || !item.icons.length ? [] : item.icons, @@ -132,8 +132,13 @@ export const mapToCard = ({ isUnverified, isUpdateAvailable, extraLabelsBadges, - installStatus: item.type === 'integration' ? item.installationInfo?.install_status : null, }; + + if (item.type === 'integration') { + cardResult.installStatus = item.installationInfo?.install_status; + } + + return cardResult; }; export function getIntegrationLabels(item: PackageListItem): React.ReactNode[] { From 80b6ee306f7f9e5aef97bd71c891f4708d31f993 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Fri, 27 Sep 2024 18:01:22 +0100 Subject: [PATCH 15/35] agentless available callout --- .../cards/integrations/integrations_card.tsx | 57 +++++++++++++++++-- .../integrations_check_complete.test.ts | 4 ++ .../integrations_check_complete.ts | 4 ++ .../onboarding/service/onboarding_service.ts | 16 +++++- .../public/onboarding/onboarding.ts | 1 + 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index 03b1f9898bcdc..9486d8f0f8125 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -5,8 +5,10 @@ * 2.0. */ import React, { useCallback } from 'react'; -import { EuiSpacer, EuiIcon } from '@elastic/eui'; +import { EuiSpacer, EuiIcon, useEuiTheme } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useObservable } from 'react-use'; +import { css } from '@emotion/react'; import type { OnboardingCardComponent } from '../../../../types'; import { OnboardingCardContentPanel } from '../common/card_content_panel'; import { CardCallOut } from '../common/card_callout'; @@ -14,21 +16,66 @@ import { AvailablePackages } from './available_packages'; import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; import { LinkAnchor } from '../../../../../common/components/links'; import { useKibana } from '../../../../../common/lib/kibana'; +import { useOnboardingService } from '../../../../hooks/use_onboarding_service'; export const IntegrationsCard: OnboardingCardComponent = ({ checkCompleteMetadata, // this is undefined before the first checkComplete call finishes }) => { - const integrationsInstalled: number | undefined = checkCompleteMetadata?.integrationsInstalled as - | number - | undefined; + const integrationsInstalled: number = checkCompleteMetadata?.integrationsInstalled as number; const { href, onClick } = useAddIntegrationsUrl(); - const { navigateToApp } = useKibana().services.application; + const { + application: { navigateToApp }, + } = useKibana().services; + const { isAgentlessAvailable$ } = useOnboardingService(); + const isAgentlessAvailable = useObservable(isAgentlessAvailable$, undefined); + const { euiTheme } = useEuiTheme(); + const onAddAgentClick = useCallback(() => { navigateToApp('fleet', { path: '/agents' }); }, [navigateToApp]); return ( + {!integrationsInstalled && isAgentlessAvailable && ( + <> + , + new: ( + + ), + text: ( + + ), + link: ( + + + + ), + }} + /> + } + /> + + + )} {integrationsInstalled && ( <> { expect(result).toEqual({ isComplete: false, + metadata: { + integrationsInstalled: 0, + agentStillRequired: false, + }, }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts index dced47a256b15..34e09a0f0f86c 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts @@ -51,6 +51,10 @@ export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async if (!isComplete) { return { isComplete, + metadata: { + integrationsInstalled: 0, + agentStillRequired: false, + }, }; } diff --git a/x-pack/plugins/security_solution/public/onboarding/service/onboarding_service.ts b/x-pack/plugins/security_solution/public/onboarding/service/onboarding_service.ts index 12cb9fc001b0d..c894d71996f53 100644 --- a/x-pack/plugins/security_solution/public/onboarding/service/onboarding_service.ts +++ b/x-pack/plugins/security_solution/public/onboarding/service/onboarding_service.ts @@ -8,17 +8,31 @@ import { BehaviorSubject, type Observable } from 'rxjs'; type UserUrl = string | undefined; +type IsAgentlessAvailable = boolean | undefined; export class OnboardingService { private usersUrlSubject$: BehaviorSubject; public usersUrl$: Observable; + private isAgentlessAvailableSubject$: BehaviorSubject; + public isAgentlessAvailable$: Observable; + constructor() { this.usersUrlSubject$ = new BehaviorSubject(undefined); this.usersUrl$ = this.usersUrlSubject$.asObservable(); + + this.isAgentlessAvailableSubject$ = new BehaviorSubject(undefined); + this.isAgentlessAvailable$ = this.isAgentlessAvailableSubject$.asObservable(); } - public setSettings({ userUrl }: { userUrl: UserUrl }) { + public setSettings({ + userUrl, + isAgentlessAvailable, + }: { + userUrl: UserUrl; + isAgentlessAvailable: boolean; + }) { this.usersUrlSubject$.next(userUrl); + this.isAgentlessAvailableSubject$.next(isAgentlessAvailable); } } diff --git a/x-pack/plugins/security_solution_serverless/public/onboarding/onboarding.ts b/x-pack/plugins/security_solution_serverless/public/onboarding/onboarding.ts index e34284e2e7ecb..e371ccfc42c2a 100644 --- a/x-pack/plugins/security_solution_serverless/public/onboarding/onboarding.ts +++ b/x-pack/plugins/security_solution_serverless/public/onboarding/onboarding.ts @@ -13,5 +13,6 @@ export const setOnboardingSettings = (services: Services) => { securitySolution.setOnboardingSettings({ userUrl: getCloudUrl('usersAndRoles', cloud), + isAgentlessAvailable: true, }); }; From 2487395cc9559a9b88fe9cfb90799f64734a7209 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 30 Sep 2024 15:55:18 +0100 Subject: [PATCH 16/35] update routes state --- .../agentless_available_callout.tsx | 69 +++++++++++ .../cards/integrations/const.ts | 1 + .../cards/integrations/hooks.ts | 9 +- .../cards/integrations/integrations_card.tsx | 115 +++--------------- .../packages_installed_callout.tsx | 84 +++++++++++++ .../cards/integrations/utils.ts | 67 ++++++---- 6 files changed, 218 insertions(+), 127 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.tsx new file mode 100644 index 0000000000000..87b4ddf340051 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.tsx @@ -0,0 +1,69 @@ +/* + * 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 from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiIcon, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +import { LinkAnchor } from '../../../../../common/components/links'; +import { CardCallOut } from '../common/card_callout'; + +export const AgentlessAvailableCallout = React.memo( + ({ addAgentLink, onAddAgentClick }: { addAgentLink: string; onAddAgentClick: () => void }) => { + const { euiTheme } = useEuiTheme(); + + return ( + , + new: ( + + + + ), + text: ( + + ), + link: ( + + + + ), + }} + /> + } + /> + ); + } +); + +AgentlessAvailableCallout.displayName = 'AgentlessAvailableCallout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts index 5ab98a111d43a..1cb1cf556c3a6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts @@ -19,6 +19,7 @@ export const CARD_TITLE_LINE_CLAMP = 1; // 1 line of text export const CARD_DESCRIPTION_LINE_CLAMP = 3; // 3 lines of text export const LOADING_SKELETON_HEIGHT = 10; // 10 lines of text export const AGENT_INDEX = `logs-elastic_agent*`; +export const INTEGRATION_APP_ID = `integrations`; export const INTEGRATION_TABS: Tab[] = [ { category: 'security', diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts index 0a244a85140f9..332f5bd692e79 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts @@ -8,7 +8,7 @@ import { useMemo } from 'react'; import type { IntegrationCardItem } from '@kbn/fleet-plugin/public'; -import { useKibana } from '../../../../../common/lib/kibana'; +import { useNavigation } from '../../../../../common/lib/kibana'; import { getFilteredCards } from './utils'; import { INTEGRATION_TABS } from './const'; @@ -19,11 +19,10 @@ export const useIntegrationCardList = ({ integrationsList: IntegrationCardItem[]; customCardNames?: string[] | undefined; }): IntegrationCardItem[] => { - const kibana = useKibana(); - const basePath = kibana.services.http?.basePath.get(); + const { navigateTo, getAppUrl } = useNavigation(); const { featuredCards, integrationCards } = useMemo( - () => getFilteredCards(integrationsList, customCardNames, basePath), - [integrationsList, customCardNames, basePath] + () => getFilteredCards({ navigateTo, getAppUrl, integrationsList, customCardNames }), + [navigateTo, getAppUrl, integrationsList, customCardNames] ); if (customCardNames && customCardNames.length > 0) { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index 9486d8f0f8125..f2d7ad62cee23 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -5,125 +5,46 @@ * 2.0. */ import React, { useCallback } from 'react'; -import { EuiSpacer, EuiIcon, useEuiTheme } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiSpacer } from '@elastic/eui'; import { useObservable } from 'react-use'; -import { css } from '@emotion/react'; import type { OnboardingCardComponent } from '../../../../types'; import { OnboardingCardContentPanel } from '../common/card_content_panel'; -import { CardCallOut } from '../common/card_callout'; import { AvailablePackages } from './available_packages'; -import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; -import { LinkAnchor } from '../../../../../common/components/links'; -import { useKibana } from '../../../../../common/lib/kibana'; +import { useNavigation } from '../../../../../common/lib/kibana'; import { useOnboardingService } from '../../../../hooks/use_onboarding_service'; +import { AgentlessAvailableCallout } from './agentless_available_callout'; +import { PackageInstalledCallout } from './packages_installed_callout'; export const IntegrationsCard: OnboardingCardComponent = ({ checkCompleteMetadata, // this is undefined before the first checkComplete call finishes }) => { const integrationsInstalled: number = checkCompleteMetadata?.integrationsInstalled as number; - const { href, onClick } = useAddIntegrationsUrl(); - const { - application: { navigateToApp }, - } = useKibana().services; + const { isAgentlessAvailable$ } = useOnboardingService(); const isAgentlessAvailable = useObservable(isAgentlessAvailable$, undefined); - const { euiTheme } = useEuiTheme(); + const { getAppUrl, navigateTo } = useNavigation(); + const addAgentLink = getAppUrl({ appId: 'fleet', path: '/agents' }); const onAddAgentClick = useCallback(() => { - navigateToApp('fleet', { path: '/agents' }); - }, [navigateToApp]); - + navigateTo({ appId: 'fleet', path: '/agents' }); // to be confirmed + }, [navigateTo]); return ( - {!integrationsInstalled && isAgentlessAvailable && ( + {isAgentlessAvailable && integrationsInstalled === 0 && ( <> - , - new: ( - - ), - text: ( - - ), - link: ( - - - - ), - }} - /> - } + )} - {integrationsInstalled && ( + {(integrationsInstalled > 0 || checkCompleteMetadata?.agentStillRequired) && ( <> - - ) : ( - - ), - desc2: checkCompleteMetadata?.agentStillRequired ? ( - - - - ) : ( - - - - ), - icon: , - }} - /> - } + diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx new file mode 100644 index 0000000000000..018c177c24213 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx @@ -0,0 +1,84 @@ +/* + * 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 from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiIcon } from '@elastic/eui'; + +import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; +import { LinkAnchor } from '../../../../../common/components/links'; +import { CardCallOut } from '../common/card_callout'; + +export const PackageInstalledCallout = React.memo( + ({ + addAgentLink, + checkCompleteMetadata, + onAddAgentClick, + }: { + addAgentLink: string; + checkCompleteMetadata: Record | undefined; + onAddAgentClick: () => void; + }) => { + const { href: integrationUrl, onClick: onAddIntegrationClicked } = useAddIntegrationsUrl(); + const integrationsInstalled: number = checkCompleteMetadata?.integrationsInstalled as number; + + return ( + + ) : ( + + ), + desc2: checkCompleteMetadata?.agentStillRequired ? ( + + + + ) : ( + + + + ), + icon: , + }} + /> + } + /> + ); + } +); + +PackageInstalledCallout.displayName = 'PackageInstalledCallout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts index 37734ada7b312..79b7bef379edb 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts @@ -7,21 +7,22 @@ import { lazy } from 'react'; import type { AvailablePackagesHookType, IntegrationCardItem } from '@kbn/fleet-plugin/public'; -import { isEmpty } from 'lodash'; +import { SECURITY_UI_APP_ID } from '@kbn/security-solution-navigation'; import { APP_INTEGRATIONS_PATH, - APP_PATH, APP_UI_ID, ONBOARDING_PATH, } from '../../../../../../common/constants'; import { CARD_DESCRIPTION_LINE_CLAMP, CARD_TITLE_LINE_CLAMP, + INTEGRATION_APP_ID, MAX_CARD_HEIGHT, ONBOARDING_APP_ID, ONBOARDING_LINK, } from './const'; +import type { GetAppUrl, NavigateTo } from '../../../../../common/lib/kibana'; export const PackageList = lazy(async () => ({ default: await import('@kbn/fleet-plugin/public') @@ -46,21 +47,28 @@ export const extractFeaturedCards = ( }, {}); }; -export const getFilteredCards = ( - integrationsList: IntegrationCardItem[], - customCards?: string[], - basePath?: string, - installedIntegrationList?: IntegrationCardItem[] -) => { +export const getFilteredCards = ({ + customCardNames, + getAppUrl, + installedIntegrationList, + integrationsList, + navigateTo, +}: { + customCardNames?: string[]; + getAppUrl: GetAppUrl; + installedIntegrationList?: IntegrationCardItem[]; + integrationsList: IntegrationCardItem[]; + navigateTo: NavigateTo; +}) => { const securityIntegrationsList = integrationsList.map((card) => - addSecuritySpecificProps({ card, basePath, installedIntegrationList }) + addSecuritySpecificProps({ navigateTo, getAppUrl, card, installedIntegrationList }) ); - if (!customCards) { + if (!customCardNames) { return { featuredCards: {}, integrationCards: securityIntegrationsList }; } return { - featuredCards: extractFeaturedCards(securityIntegrationsList, customCards), + featuredCards: extractFeaturedCards(securityIntegrationsList, customCardNames), integrationCards: securityIntegrationsList, }; }; @@ -75,31 +83,40 @@ const addPathParamToUrl = (url: string, onboardingLink: string) => { return `${url}?${paramsString}`; }; -const getOnboardingPath = (basePath?: string): string | null => { - const onboardingPath = `${APP_PATH}${ONBOARDING_PATH}`; - const path = !isEmpty(basePath) ? `${basePath}/${onboardingPath}` : onboardingPath; - - return path; -}; - const addSecuritySpecificProps = ({ - basePath, + navigateTo, + getAppUrl, card, }: { - basePath?: string; + navigateTo: NavigateTo; + getAppUrl: GetAppUrl; card: IntegrationCardItem; installedIntegrationList?: IntegrationCardItem[]; }): IntegrationCardItem => { - const onboardingLink = getOnboardingPath(basePath); + const onboardingLink = getAppUrl({ appId: SECURITY_UI_APP_ID, path: ONBOARDING_PATH }); + const integrationRootUrl = getAppUrl({ appId: INTEGRATION_APP_ID }); + const state = { + onCancelNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], + onCancelUrl: onboardingLink, + onSaveNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], + }; + const url = + card.url.indexOf(APP_INTEGRATIONS_PATH) >= 0 && onboardingLink + ? addPathParamToUrl(card.url, onboardingLink) + : card.url; return { ...card, titleLineClamp: CARD_TITLE_LINE_CLAMP, descriptionLineClamp: CARD_DESCRIPTION_LINE_CLAMP, maxCardHeight: MAX_CARD_HEIGHT, showInstallationStatus: true, - url: - card.url.indexOf(APP_INTEGRATIONS_PATH) >= 0 && onboardingLink - ? addPathParamToUrl(card.url, onboardingLink) - : card.url, + url, + onCardClick: () => { + navigateTo({ + appId: INTEGRATION_APP_ID, + path: url.slice(integrationRootUrl.length), + state, + }); + }, }; }; From 0ff541bed60f3d86e57e8606d55c009634bfe5c5 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 30 Sep 2024 15:55:58 +0100 Subject: [PATCH 17/35] update onSave link in fleet --- .../integrations/sections/epm/screens/detail/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index 1df854b3e9eed..c46e1f280adb7 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -412,6 +412,7 @@ export function Detail() { ...(defaultNavigateOptions[1]?.state ?? {}), onCancelNavigateTo: [onboardingAppId, { path: onboardingLink }], onCancelUrl: onboardingLink, + onSaveNavigateTo: [onboardingAppId, { path: onboardingLink }], }, }, ] From 9621486ed49a339028d0d723f28e2e1008d35a46 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 30 Sep 2024 18:09:00 +0100 Subject: [PATCH 18/35] update unit tests --- .../cards/common/card_content_panel.tsx | 8 +- .../agentless_available_callout.test.tsx | 31 ++++++ .../cards/integrations/hooks.test.ts | 32 +++--- .../integrations/integrations_card.test.tsx | 97 +++++++++++++------ .../cards/integrations/integrations_card.tsx | 1 - .../integrations/package_list_grid.test.tsx | 2 +- .../packages_installed_callout.test.tsx | 77 +++++++++++++++ .../packages_installed_callout.tsx | 1 - .../cards/integrations/utils.test.ts | 54 ++++++++++- 9 files changed, 252 insertions(+), 51 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx index 0996a1ced6380..444f32de7348d 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx @@ -5,17 +5,19 @@ * 2.0. */ import React, { type PropsWithChildren } from 'react'; -import { EuiPanel, type EuiPanelProps } from '@elastic/eui'; +import { EuiPanel, useEuiTheme, type EuiPanelProps } from '@elastic/eui'; +import { css } from '@emotion/react'; export const OnboardingCardContentPanel = React.memo>( ({ children, ...panelProps }) => { + const { euiTheme } = useEuiTheme(); return ( diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.test.tsx new file mode 100644 index 0000000000000..017e42a51766d --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.test.tsx @@ -0,0 +1,31 @@ +/* + * 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 { render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../../../common/mock/test_providers'; +import { AgentlessAvailableCallout } from './agentless_available_callout'; + +const props = { + addAgentLink: '', + onAddAgentClick: jest.fn(), +}; + +describe('AgentlessAvailableCallout', () => { + it('renders the agentless available text', () => { + const { getByText, getByTestId } = render(, { + wrapper: TestProviders, + }); + expect(getByText('NEW')).toBeInTheDocument(); + expect( + getByText( + 'Identify configuration risks in your cloud account with new and simplified agentless configuration' + ) + ).toBeInTheDocument(); + expect(getByTestId('agentlessLearnMoreLink')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts index 8e4bb511177bb..ca9af25097559 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts @@ -6,11 +6,11 @@ */ import { renderHook } from '@testing-library/react-hooks'; import { useIntegrationCardList, useTabMetaData } from './hooks'; -import { useKibana } from '../../../../../common/lib/kibana'; +import { useNavigation } from '../../../../../common/lib/kibana'; import { getFilteredCards } from './utils'; jest.mock('../../../../../common/lib/kibana', () => ({ - useKibana: jest.fn(), + useNavigation: jest.fn(), })); jest.mock('./utils', () => ({ @@ -18,7 +18,9 @@ jest.mock('./utils', () => ({ })); describe('useIntegrationCardList', () => { - const mockBasePath = '/mock/base/path'; + const mockUseNavigation = useNavigation as jest.Mock; + const mockNavigateTo = jest.fn(); + const mockGetAppUrl = jest.fn(); const mockIntegrationsList = [ { id: 'security', @@ -26,7 +28,6 @@ describe('useIntegrationCardList', () => { description: 'Integration for security monitoring', categories: ['security'], icons: [{ src: 'icon_url', type: 'image' }], - installStatus: null, integration: 'security', title: 'Security Integration', url: '/app/integrations/security', @@ -35,10 +36,9 @@ describe('useIntegrationCardList', () => { ]; beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - http: { basePath: { get: () => mockBasePath } }, - }, + mockUseNavigation.mockReturnValue({ + navigateTo: mockNavigateTo, + getAppUrl: mockGetAppUrl, }); }); @@ -55,7 +55,12 @@ describe('useIntegrationCardList', () => { }) ); - expect(getFilteredCards).toHaveBeenCalledWith(mockIntegrationsList, undefined, mockBasePath); + expect(getFilteredCards).toHaveBeenCalledWith({ + integrationsList: mockIntegrationsList, + customCardNames: undefined, + navigateTo: mockNavigateTo, + getAppUrl: mockGetAppUrl, + }); expect(result.current).toEqual(mockFilteredCards.integrationCards); }); @@ -76,11 +81,12 @@ describe('useIntegrationCardList', () => { }) ); - expect(getFilteredCards).toHaveBeenCalledWith( - mockIntegrationsList, + expect(getFilteredCards).toHaveBeenCalledWith({ + integrationsList: mockIntegrationsList, customCardNames, - mockBasePath - ); + navigateTo: mockNavigateTo, + getAppUrl: mockGetAppUrl, + }); expect(result.current).toEqual([mockFilteredCards.featuredCards['Security Integration']]); }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx index caba01dba55fb..0dc18a3323234 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx @@ -5,26 +5,25 @@ * 2.0. */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; import { IntegrationsCard } from './integrations_card'; import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; -import { useKibana } from '../../../../../common/lib/kibana'; +import { useNavigation } from '../../../../../common/lib/kibana'; +import { of } from 'rxjs'; +import { useOnboardingService } from '../../../../hooks/use_onboarding_service'; +import { TestProviders } from '../../../../../common/mock/test_providers'; jest.mock('../../../../../common/hooks/use_add_integrations_url'); jest.mock('../../../../../common/lib/kibana'); jest.mock('./available_packages', () => ({ AvailablePackages: () =>
, })); +jest.mock('../../../../hooks/use_onboarding_service', () => ({ + useOnboardingService: jest.fn(), +})); -const mockNavigateToApp = jest.fn(); -(useKibana as jest.Mock).mockReturnValue({ - services: { - application: { - navigateToApp: mockNavigateToApp, - }, - }, -}); - +const mockGetAppUrl = jest.fn(); +const mockNavigateTo = jest.fn(); const mockOnAddIntegrationsUrl = jest.fn(); (useAddIntegrationsUrl as jest.Mock).mockReturnValue({ href: '/integrations', @@ -40,61 +39,103 @@ const props = { describe('IntegrationsCard', () => { beforeEach(() => { jest.clearAllMocks(); + (useOnboardingService as jest.Mock).mockReturnValue({ isAgentlessAvailable$: of(false) }); + (useNavigation as jest.Mock).mockReturnValue({ + getAppUrl: mockGetAppUrl, + navigateTo: mockNavigateTo, + }); }); - it('renders the callout and available packages when integrationsInstalled is present', () => { + it('renders the callout and available packages when integrations are Installed', async () => { const mockMetadata = { integrationsInstalled: 3, agentStillRequired: false, }; - const { getByTestId } = render( - + const { getByTestId, getByText } = render( + , + { wrapper: TestProviders } + ); + await waitFor(() => { + expect(getByText('3 integrations have been added')).toBeInTheDocument(); + expect(getByTestId('manageIntegrationsLink')).toBeInTheDocument(); + }); + }); + + it('renders the agentless available callout and available packages', async () => { + (useOnboardingService as jest.Mock).mockReturnValue({ isAgentlessAvailable$: of(true) }); + + const mockMetadata = { + integrationsInstalled: 0, + agentStillRequired: false, + }; + + const { getByTestId, getByText } = render( + , + { wrapper: TestProviders } ); - expect(getByTestId('integrationsCompleteText')).toBeInTheDocument(); - expect(getByTestId('manageIntegrationsLink')).toBeInTheDocument(); + await waitFor(() => { + expect( + getByText( + 'Identify configuration risks in your cloud account with new and simplified agentless configuration' + ) + ).toBeInTheDocument(); + expect(getByTestId('agentlessLearnMoreLink')).toBeInTheDocument(); + }); }); - it('renders the warning callout when an agent is still required', () => { + it('renders the warning callout when an agent is still required', async () => { const mockMetadata = { integrationsInstalled: 2, agentStillRequired: true, }; - const { getByTestId } = render( - + const { getByTestId, getByText } = render( + , + { wrapper: TestProviders } ); - - expect(getByTestId('agentRequiredText')).toBeInTheDocument(); - expect(getByTestId('agentLink')).toBeInTheDocument(); + await waitFor(() => { + expect( + getByText( + 'Elastic Agent is required for one or more of your integrations. Add Elastic Agent' + ) + ).toBeInTheDocument(); + expect(getByTestId('agentLink')).toBeInTheDocument(); + }); }); - it('handles navigation to the Fleet app when Add Agent is clicked', () => { + it('handles navigation to the Fleet app when Add Agent is clicked', async () => { const mockMetadata = { integrationsInstalled: 1, agentStillRequired: true, }; const { getByTestId } = render( - + , + { wrapper: TestProviders } ); fireEvent.click(getByTestId('agentLink')); - expect(mockNavigateToApp).toHaveBeenCalledWith('fleet', { path: '/agents' }); + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalledWith({ appId: 'fleet', path: '/agents' }); + }); }); - it('handles clicking on the Manage integrations link', () => { + it('handles clicking on the Manage integrations link', async () => { const mockMetadata = { integrationsInstalled: 3, agentStillRequired: false, }; const { getByTestId } = render( - + , + { wrapper: TestProviders } ); fireEvent.click(getByTestId('manageIntegrationsLink')); - expect(mockOnAddIntegrationsUrl).toHaveBeenCalled(); + await waitFor(() => { + expect(mockOnAddIntegrationsUrl).toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index f2d7ad62cee23..16985fdc2551d 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -22,7 +22,6 @@ export const IntegrationsCard: OnboardingCardComponent = ({ const { isAgentlessAvailable$ } = useOnboardingService(); const isAgentlessAvailable = useObservable(isAgentlessAvailable$, undefined); - const { getAppUrl, navigateTo } = useNavigation(); const addAgentLink = getAppUrl({ appId: 'fleet', path: '/agents' }); const onAddAgentClick = useCallback(() => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx index 26ca7507554b8..e753148493310 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React from 'react'; -import { render, fireEvent, waitFor, act } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import { PackageListGrid } from './package_list_grid'; import { useStoredIntegrationSearchTerm, diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx new file mode 100644 index 0000000000000..654972f6a6d61 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { PackageInstalledCallout } from './packages_installed_callout'; +import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; +import { TestProviders } from '../../../../../common/mock/test_providers'; + +jest.mock('../../../../../common/hooks/use_add_integrations_url'); + +const mockOnAddIntegrationsUrl = jest.fn(); +(useAddIntegrationsUrl as jest.Mock).mockReturnValue({ + href: '/integrations', + onClick: mockOnAddIntegrationsUrl, +}); + +const props = { + addAgentLink: '', + onAddAgentClick: jest.fn(), +}; + +describe('PackageInstalledCallout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the callout and available packages when integrations are installed', () => { + const mockMetadata = { + integrationsInstalled: 3, + agentStillRequired: false, + }; + + const { getByTestId, getByText } = render( + , + { wrapper: TestProviders } + ); + + expect(getByText('3 integrations have been added')).toBeInTheDocument(); + expect(getByTestId('manageIntegrationsLink')).toBeInTheDocument(); + }); + + it('renders the warning callout when an agent is still required', () => { + const mockMetadata = { + integrationsInstalled: 2, + agentStillRequired: true, + }; + + const { getByTestId, getByText } = render( + , + { wrapper: TestProviders } + ); + + expect( + getByText('Elastic Agent is required for one or more of your integrations. Add Elastic Agent') + ).toBeInTheDocument(); + expect(getByTestId('agentLink')).toBeInTheDocument(); + }); + + it('handles clicking on the Manage integrations link', () => { + const mockMetadata = { + integrationsInstalled: 3, + agentStillRequired: false, + }; + + const { getByTestId } = render( + , + { wrapper: TestProviders } + ); + + fireEvent.click(getByTestId('manageIntegrationsLink')); + expect(mockOnAddIntegrationsUrl).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx index 018c177c24213..02bd06c91fd1a 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx @@ -37,7 +37,6 @@ export const PackageInstalledCallout = React.memo( values={{ desc1: checkCompleteMetadata?.agentStillRequired ? ( diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts index 7c829a54b2840..a9bca99f759c9 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts @@ -6,13 +6,17 @@ */ import type { IntegrationCardItem } from '@kbn/fleet-plugin/public'; import { extractFeaturedCards, getFilteredCards } from './utils'; // Update the path accordingly +import { SECURITY_UI_APP_ID } from '@kbn/security-solution-navigation'; +import { INTEGRATION_APP_ID } from './const'; +import { APP_UI_ID, ONBOARDING_PATH } from '../../../../../../common/constants'; const maxCardHeight = 127; const cardTitleLineClamp = 1; const cardDescriptionLineClamp = 3; -const expectedUrl = `/app/integrations?onboardingLink=${encodeURIComponent( +const expectedPath = `?onboardingLink=${encodeURIComponent( '/app/security/get_started' )}&onboardingAppId=securitySolutionUI`; +const expectedUrl = `/app/integrations${expectedPath}`; const mockIntegrationCardItem = { categories: ['security'], description: 'Security integration for monitoring.', @@ -23,7 +27,6 @@ const mockIntegrationCardItem = { }, ], id: 'security-integration', - installStatus: null, integration: 'security', name: 'Security Integration', title: 'Security Integration', @@ -54,8 +57,23 @@ describe('extractFeaturedCards', () => { }); describe('getFilteredCards', () => { + const mockGetAppUrl = jest + .fn() + .mockImplementation(({ appId }) => + appId === SECURITY_UI_APP_ID ? '/app/security/get_started' : '/app/integrations' + ); + const mockNavigateTo = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('returns integration cards without featured cards when no custom cards are provided', () => { - const result = getFilteredCards(mockIntegrationCardItems); + const result = getFilteredCards({ + integrationsList: mockIntegrationCardItems, + getAppUrl: mockGetAppUrl, + navigateTo: mockNavigateTo, + }); expect(result).toEqual({ featuredCards: {}, @@ -67,6 +85,7 @@ describe('getFilteredCards', () => { maxCardHeight, showInstallationStatus: true, url: expectedUrl, + onCardClick: expect.any(Function), }, ], }); @@ -74,7 +93,12 @@ describe('getFilteredCards', () => { it('returns both featured cards and integration cards when custom cards are provided', () => { const customCards = ['Security Integration']; - const result = getFilteredCards(mockIntegrationCardItems, customCards); + const result = getFilteredCards({ + integrationsList: mockIntegrationCardItems, + customCardNames: customCards, + getAppUrl: mockGetAppUrl, + navigateTo: mockNavigateTo, + }); expect(result).toEqual({ featuredCards: { @@ -85,6 +109,7 @@ describe('getFilteredCards', () => { maxCardHeight, showInstallationStatus: true, url: expectedUrl, + onCardClick: expect.any(Function), }, }, integrationCards: [ @@ -95,8 +120,29 @@ describe('getFilteredCards', () => { maxCardHeight, showInstallationStatus: true, url: expectedUrl, + onCardClick: expect.any(Function), }, ], }); }); + + it("should update routes' state when clicking an integration card", () => { + const result = getFilteredCards({ + integrationsList: mockIntegrationCardItems, + getAppUrl: mockGetAppUrl, + navigateTo: mockNavigateTo, + }); + + result.integrationCards[0].onCardClick?.(); + + expect(mockNavigateTo).toHaveBeenCalledWith({ + appId: INTEGRATION_APP_ID, + path: expectedPath, + state: { + onCancelNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], + onCancelUrl: '/app/security/get_started', + onSaveNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], + }, + }); + }); }); From 0856719b15e612c853d135023c5294645b2efc0b Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 1 Oct 2024 13:20:01 +0100 Subject: [PATCH 19/35] fix apm link --- .../integrations_check_complete.ts | 17 ++++++----------- .../cards/integrations/utils.test.ts | 19 +++++++++++++++++++ .../cards/integrations/utils.ts | 16 +++++++++++----- .../hooks/use_completed_cards.ts | 10 +++++----- .../public/onboarding/types.ts | 13 ++++--------- 5 files changed, 45 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts index 34e09a0f0f86c..ca119c3e72d6c 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts @@ -7,26 +7,21 @@ import type { GetPackagesResponse } from '@kbn/fleet-plugin/public'; import { EPM_PACKAGES_MANY, installationStatuses } from '@kbn/fleet-plugin/public'; -import type { HttpSetup } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { lastValueFrom } from 'rxjs'; import type { OnboardingCardCheckComplete } from '../../../../types'; import { AGENT_INDEX } from './const'; +import type { StartServices } from '../../../../../types'; -export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async ({ - data, - http, -}: { - data: DataPublicPluginStart; - http: HttpSetup; -}) => { - const packageData = await http.get(EPM_PACKAGES_MANY, { +export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async ( + services: StartServices +) => { + const packageData = await services.http.get(EPM_PACKAGES_MANY, { version: '2023-10-31', }); const agentsData = await lastValueFrom( - data.search.search({ + services.data.search.search({ params: { index: AGENT_INDEX, body: { size: 1 } }, }) ); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts index a9bca99f759c9..97d8172b5c8a5 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts @@ -145,4 +145,23 @@ describe('getFilteredCards', () => { }, }); }); + + it('should handle links do not start with integration path', () => { + const result = getFilteredCards({ + integrationsList: [{ ...mockIntegrationCardItem, url: '/app/home' }], + getAppUrl: mockGetAppUrl, + navigateTo: mockNavigateTo, + }); + + result.integrationCards[0].onCardClick?.(); + + expect(mockNavigateTo).toHaveBeenCalledWith({ + url: '/app/home', + state: { + onCancelNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], + onCancelUrl: '/app/security/get_started', + onSaveNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts index 79b7bef379edb..0e3d79fe77eb5 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts @@ -112,11 +112,17 @@ const addSecuritySpecificProps = ({ showInstallationStatus: true, url, onCardClick: () => { - navigateTo({ - appId: INTEGRATION_APP_ID, - path: url.slice(integrationRootUrl.length), - state, - }); + if (url.startsWith(APP_INTEGRATIONS_PATH)) { + navigateTo({ + appId: INTEGRATION_APP_ID, + path: url.slice(integrationRootUrl.length), + state, + }); + } else if (url.startsWith('http') || url.startsWith('https')) { + window.open(url, '_blank'); + } else { + navigateTo({ url, state }); + } }, }; }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts index 926203248a61e..98eb48a02365c 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_completed_cards.ts @@ -34,7 +34,7 @@ export type CardCheckCompleteResult = Partial { const { spaceId, reportCardComplete } = useOnboardingContext(); - const { http, data } = useKibana().services; + const services = useKibana().services; // Use stored state to keep localStorage in sync, and a local state to avoid unnecessary re-renders. const [storedCompleteCardIds, setStoredCompleteCardIds] = useStoredCompletedCardIds(spaceId); @@ -113,22 +113,22 @@ export const useCompletedCards = (cardsGroupConfig: OnboardingGroupConfig[]) => const cardConfig = cardsWithAutoCheck.find(({ id }) => id === cardId); if (cardConfig) { - cardConfig.checkComplete?.({ http, data }).then((checkCompleteResult) => { + cardConfig.checkComplete?.(services).then((checkCompleteResult) => { processCardCheckCompleteResult(cardId, checkCompleteResult); }); } }, - [cardsWithAutoCheck, http, data, processCardCheckCompleteResult] + [cardsWithAutoCheck, services, processCardCheckCompleteResult] ); // Initial auto-check for all cards, it should run only once, after cardsGroupConfig is properly populated useEffect(() => { cardsWithAutoCheck.map((card) => - card.checkComplete?.({ http, data }).then((checkCompleteResult) => { + card.checkComplete?.(services).then((checkCompleteResult) => { processCardCheckCompleteResult(card.id, checkCompleteResult); }) ); - }, [cardsWithAutoCheck, http, data, processCardCheckCompleteResult]); + }, [cardsWithAutoCheck, services, processCardCheckCompleteResult]); return { isCardComplete, diff --git a/x-pack/plugins/security_solution/public/onboarding/types.ts b/x-pack/plugins/security_solution/public/onboarding/types.ts index 6c8c14fadb3c3..1f7e220a5c06b 100644 --- a/x-pack/plugins/security_solution/public/onboarding/types.ts +++ b/x-pack/plugins/security_solution/public/onboarding/types.ts @@ -8,11 +8,10 @@ import type React from 'react'; import type { IconType } from '@elastic/eui'; import type { LicenseType } from '@kbn/licensing-plugin/public'; -import type { HttpSetup, NavigateToAppOptions } from '@kbn/core/public'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { OnboardingCardId } from './constants'; import type { RequiredCapabilities } from '../common/lib/capabilities'; +import type { StartServices } from '../types'; export interface CheckCompleteResult { /** @@ -65,13 +64,9 @@ export type OnboardingCardComponent = React.ComponentType<{ checkCompleteMetadata?: Record; }>; -export type OnboardingCardCheckComplete = ({ - http, - data, -}: { - http: HttpSetup; - data: DataPublicPluginStart; -}) => Promise; +export type OnboardingCardCheckComplete = ( + services: StartServices +) => Promise; export interface OnboardingCardConfig { id: OnboardingCardId; From ca0da3645faa1b20292c1d98f8457c949fc62fc8 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 1 Oct 2024 13:48:28 +0100 Subject: [PATCH 20/35] fixup --- .../onboarding_body/onboarding_body.tsx | 15 +++------------ .../public/onboarding/hooks/use_stored_state.ts | 8 ++++---- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx index 4c316dcff16e7..e0c6e58dda91c 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx @@ -17,18 +17,9 @@ import { useCompletedCards } from './hooks/use_completed_cards'; export const OnboardingBody = React.memo(() => { const bodyConfig = useBodyConfig(); - const { expandedCardId, setExpandedCardId } = useExpandedCard(spaceId); - const { isCardComplete, setCardComplete } = useCompletedCards(spaceId); - - const { checkAllCardsComplete, checkCardComplete } = useCheckCompleteCards( - bodyConfig, - setCardComplete - ); - - useEffect(() => { - // initial auto-check for all cards - checkAllCardsComplete(); - }, [checkAllCardsComplete]); + const { expandedCardId, setExpandedCardId } = useExpandedCard(); + const { isCardComplete, setCardComplete, getCardCheckCompleteResult, checkCardComplete } = + useCompletedCards(bodyConfig); const createOnToggleExpanded = useCallback( (cardId: OnboardingCardId) => () => { diff --git a/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts b/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts index 504f3431906b6..47ad13a75c4c3 100644 --- a/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts +++ b/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts @@ -48,17 +48,17 @@ export const useStoredExpandedCardId = (spaceId: string) => ); /** - * Stores either the video card has been visited or not, per space + * Stores the selected integration tab ID per space */ -export const useStoredHasVideoVisited = (spaceId: string) => - useDefinedLocalStorage(`${LocalStorageKey.videoVisited}.${spaceId}`, false); - export const useStoredIntegrationTabId = (spaceId: string, defaultSelectedTabId: string) => useDefinedLocalStorage( `${LocalStorageKey.selectedIntegrationTabId}.${spaceId}`, defaultSelectedTabId ); +/** + * Stores the integration search term per space + */ export const useStoredIntegrationSearchTerm = (spaceId: string) => useDefinedLocalStorage( `${LocalStorageKey.IntegrationSearchTerm}.${spaceId}`, From 69c71cc94217640074310ce575f423da763335d7 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 1 Oct 2024 14:46:09 +0100 Subject: [PATCH 21/35] clean up --- .../cards/common/card_content_panel.tsx | 15 +++------------ .../onboarding_body/onboarding_body.tsx | 1 + 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx index 444f32de7348d..cbbe46d5cee60 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx @@ -5,22 +5,13 @@ * 2.0. */ import React, { type PropsWithChildren } from 'react'; -import { EuiPanel, useEuiTheme, type EuiPanelProps } from '@elastic/eui'; -import { css } from '@emotion/react'; +import { EuiPanel, type EuiPanelProps } from '@elastic/eui'; export const OnboardingCardContentPanel = React.memo>( ({ children, ...panelProps }) => { - const { euiTheme } = useEuiTheme(); return ( - - + + {children} diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx index e0c6e58dda91c..d363bb702d192 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/onboarding_body.tsx @@ -17,6 +17,7 @@ import { useCompletedCards } from './hooks/use_completed_cards'; export const OnboardingBody = React.memo(() => { const bodyConfig = useBodyConfig(); + const { expandedCardId, setExpandedCardId } = useExpandedCard(); const { isCardComplete, setCardComplete, getCardCheckCompleteResult, checkCardComplete } = useCompletedCards(bodyConfig); From 8da2fc8e5d08bc2c2fb2e950d5e458e478580bed Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 1 Oct 2024 15:08:31 +0100 Subject: [PATCH 22/35] update installation label --- .../epm/components/installation_status.test.tsx | 2 +- .../sections/epm/components/installation_status.tsx | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.test.tsx index 2d47b44b487e0..a37730ad1570c 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.test.tsx @@ -97,7 +97,7 @@ describe('InstallationStatus', () => { showInstallationStatus={true} /> ); - expect(screen.getByText('Install Failed')).toBeInTheDocument(); + expect(screen.getByText('Installed')).toBeInTheDocument(); }); it('renders null when installStatus is null or undefined', () => { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx index a345822184051..d053fd76fb391 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx @@ -15,22 +15,22 @@ import { css } from '@emotion/react'; import { installationStatuses } from '../../../../../../common/constants'; import type { EpmPackageInstallStatus } from '../../../../../../common/types'; +const installedLabel = i18n.translate('xpack.fleet.packageCard.installedLabel', { + defaultMessage: 'Installed', +}); + const installStatusMapToColor: Readonly< Record > = { installed: { color: 'success', iconType: 'check', - title: i18n.translate('xpack.fleet.packageCard.installedLabel', { - defaultMessage: 'Installed', - }), + title: installedLabel, }, install_failed: { color: 'warning', iconType: 'warning', - title: i18n.translate('xpack.fleet.packageCard.installFailedLabel', { - defaultMessage: 'Install Failed', - }), + title: installedLabel, }, }; From 3733369f6ef5c3037e71b4ad20e3837ae718501e Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 1 Oct 2024 16:38:51 +0100 Subject: [PATCH 23/35] types --- .../epm/components/utils/promote_featured_integrations.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.test.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.test.ts index 11fe989aabd7a..2c3e07921c6e8 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.test.ts +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/utils/promote_featured_integrations.test.ts @@ -21,7 +21,6 @@ const mockCard = (name: string) => release: 'ga', categories: [], isUnverified: false, - installStatus: null, } as IntegrationCardItem); const intA = mockCard('A'); From 49e182ed34156efda7f6a83910ce99e169faaa82 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 3 Oct 2024 09:51:55 +0100 Subject: [PATCH 24/35] add comments in fleet --- x-pack/plugins/fleet/common/constants/routes.ts | 2 +- .../sections/epm/components/package_list_grid/index.tsx | 1 + .../sections/epm/screens/detail/components/back_link.tsx | 1 + .../integrations/sections/epm/screens/detail/index.tsx | 6 ++++++ .../integrations/sections/epm/screens/home/card_utils.tsx | 2 ++ x-pack/plugins/fleet/public/constants/index.ts | 1 - x-pack/plugins/fleet/public/index.ts | 2 +- 7 files changed, 12 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index e096a33d830f0..9b5c35c3b3ce2 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -20,7 +20,7 @@ export const DOWNLOAD_SOURCE_API_ROOT = `${API_ROOT}/agent_download_sources`; export const LIMITED_CONCURRENCY_ROUTE_TAG = 'ingest:limited-concurrency'; // EPM API routes -export const EPM_PACKAGES_MANY = `${EPM_API_ROOT}/packages`; +const EPM_PACKAGES_MANY = `${EPM_API_ROOT}/packages`; const EPM_PACKAGES_INSTALLED = `${EPM_API_ROOT}/packages/installed`; const EPM_PACKAGES_BULK = `${EPM_PACKAGES_MANY}/_bulk`; const EPM_PACKAGES_ONE_DEPRECATED = `${EPM_PACKAGES_MANY}/{pkgkey}`; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx index 829cc23a0d646..be2b873c317db 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx @@ -71,6 +71,7 @@ export interface PackageListGridProps { showControls?: boolean; showSearchTools?: boolean; spacer?: boolean; + // Security Solution sends the id to determine which element to scroll when the user interacting with the package list scrollElementId?: string; } diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx index 76aa46fd3a56c..75d3461bdfee6 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx @@ -18,6 +18,7 @@ export function BackLink({ queryParams, href: integrationsHref }: Props) { const { onboardingLink } = useMemo(() => { return { onboardingLink: + // Users from Security Solution onboarding page will have onboardingLink to redirect back to the onboarding page queryParams.get('observabilityOnboardingLink') || queryParams.get('onboardingLink'), }; }, [queryParams]); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index c46e1f280adb7..5906aae5f4df8 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -136,6 +136,9 @@ export function Detail() { const queryParams = useMemo(() => new URLSearchParams(search), [search]); const integration = useMemo(() => queryParams.get('integration'), [queryParams]); const prerelease = useMemo(() => Boolean(queryParams.get('prerelease')), [queryParams]); + /** Users from Security Solution onboarding page will have onboardingLink and onboardingAppId in the query params + ** to redirect back to the onboarding page after adding an integration + */ const onboardingLink = useMemo(() => queryParams.get('onboardingLink'), [queryParams]); const onboardingAppId = useMemo(() => queryParams.get('onboardingAppId'), [queryParams]); @@ -402,6 +405,9 @@ export function Detail() { pkgkey, }); + /** Users from Security Solution onboarding page will have onboardingLink and onboardingAppId in the query params + ** to redirect back to the onboarding page after adding an integration + */ const navigateOptions: InstallPkgRouteOptions = onboardingAppId && onboardingLink ? [ diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx index 0aeb49ac7f1df..33d6d4d5e38f5 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx @@ -41,6 +41,7 @@ import type { PackageListItem } from '../../../../types'; export interface IntegrationCardItem { categories: string[]; description: string; + // Security Solution uses this prop to determine how many lines the card description should be truncated descriptionLineClamp?: number; extraLabelsBadges?: React.ReactNode[]; maxCardHeight?: number; @@ -60,6 +61,7 @@ export interface IntegrationCardItem { showInstallationStatus?: boolean; showLabels?: boolean; title: string; + // Security Solution uses this prop to determine how many lines the card title should be truncated titleLineClamp?: number; url: string; version: string; diff --git a/x-pack/plugins/fleet/public/constants/index.ts b/x-pack/plugins/fleet/public/constants/index.ts index 56bd3a9e7a9f7..4fbe799aa7337 100644 --- a/x-pack/plugins/fleet/public/constants/index.ts +++ b/x-pack/plugins/fleet/public/constants/index.ts @@ -33,7 +33,6 @@ export { AGENT_POLICY_MAPPINGS, AGENT_MAPPINGS, ENROLLMENT_API_KEY_MAPPINGS, - EPM_PACKAGES_MANY, } from '../../common/constants'; export * from './page_paths'; diff --git a/x-pack/plugins/fleet/public/index.ts b/x-pack/plugins/fleet/public/index.ts index d75b66f1fe931..d82e9c88b7db8 100644 --- a/x-pack/plugins/fleet/public/index.ts +++ b/x-pack/plugins/fleet/public/index.ts @@ -56,7 +56,7 @@ export type { UIExtensionsStorage, } from './types/ui_extensions'; -export { pagePathGetters, EPM_PACKAGES_MANY } from './constants'; +export { pagePathGetters, EPM_API_ROUTES } from './constants'; export { pkgKeyFromPackageInfo } from './services'; export type { CustomAssetsAccordionProps } from './components/custom_assets_accordion'; export { CustomAssetsAccordion } from './components/custom_assets_accordion'; From 03adb15f17efc54e3a5e3f8f8f76f71116971cca Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 3 Oct 2024 13:32:18 +0100 Subject: [PATCH 25/35] tests --- .../__mocks__/agent_required_callout.tsx | 10 ++ .../__mocks__/agentless_available_callout.tsx | 10 ++ .../__mocks__/available_packages.tsx | 10 ++ .../__mocks__/packages_installed_callout.tsx | 10 ++ .../agent_required_callout.test.tsx | 33 ++++++ .../integrations/agent_required_callout.tsx | 55 +++++++++ .../agentless_available_callout.test.tsx | 25 ++++- .../agentless_available_callout.tsx | 100 +++++++++-------- .../cards/integrations/const.ts | 19 ++-- .../integrations/integrations_card.test.tsx | 106 +++--------------- .../cards/integrations/integrations_card.tsx | 40 +++---- .../integrations_check_complete.test.ts | 39 +++---- .../integrations_check_complete.ts | 11 +- .../packages_installed_callout.test.tsx | 28 ++--- .../packages_installed_callout.tsx | 43 +++---- 15 files changed, 287 insertions(+), 252 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agent_required_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agentless_available_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/available_packages.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/packages_installed_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.tsx diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agent_required_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agent_required_callout.tsx new file mode 100644 index 0000000000000..9a8fb5c014169 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agent_required_callout.tsx @@ -0,0 +1,10 @@ +/* + * 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 from 'react'; + +export const AgentRequiredCallout = () =>
; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agentless_available_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agentless_available_callout.tsx new file mode 100644 index 0000000000000..2f7ad32e5fc8c --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agentless_available_callout.tsx @@ -0,0 +1,10 @@ +/* + * 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 from 'react'; + +export const AgentlessAvailableCallout = () =>
; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/available_packages.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/available_packages.tsx new file mode 100644 index 0000000000000..b05ce569544f3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/available_packages.tsx @@ -0,0 +1,10 @@ +/* + * 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 from 'react'; + +export const AvailablePackages = () =>
; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/packages_installed_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/packages_installed_callout.tsx new file mode 100644 index 0000000000000..922544ad2a260 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/packages_installed_callout.tsx @@ -0,0 +1,10 @@ +/* + * 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 from 'react'; + +export const PackageInstalledCallout = () =>
; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.test.tsx new file mode 100644 index 0000000000000..3502c54be0365 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.test.tsx @@ -0,0 +1,33 @@ +/* + * 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. + */ +/* + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { AgentRequiredCallout } from './agent_required_callout'; +import { TestProviders } from '../../../../../common/mock/test_providers'; + +jest.mock('../../../../../common/lib/kibana'); + +describe('AgentRequiredCallout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the warning callout when an agent is still required', () => { + const { getByTestId, getByText } = render(, { wrapper: TestProviders }); + + expect( + getByText('Elastic Agent is required for one or more of your integrations. Add Elastic Agent') + ).toBeInTheDocument(); + expect(getByTestId('agentLink')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.tsx new file mode 100644 index 0000000000000..ae0e5d8d9e580 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.tsx @@ -0,0 +1,55 @@ +/* + * 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, { useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiIcon } from '@elastic/eui'; + +import { LinkAnchor } from '../../../../../common/components/links'; +import { CardCallOut } from '../common/card_callout'; +import { useNavigation } from '../../../../../common/lib/kibana'; +import { FLEET_APP_ID, ADD_AGENT_PATH } from './const'; + +export const AgentRequiredCallout = React.memo(() => { + const { getAppUrl, navigateTo } = useNavigation(); + const addAgentLink = getAppUrl({ appId: FLEET_APP_ID, path: ADD_AGENT_PATH }); + const onAddAgentClick = useCallback(() => { + navigateTo({ appId: FLEET_APP_ID, path: ADD_AGENT_PATH }); + }, [navigateTo]); + + return ( + + ), + link: ( + + + + ), + icon: , + }} + /> + } + /> + ); +}); + +AgentRequiredCallout.displayName = 'AgentRequiredCallout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.test.tsx index 017e42a51766d..f9bf4cd2967bc 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.test.tsx @@ -9,15 +9,30 @@ import { render } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../../../common/mock/test_providers'; import { AgentlessAvailableCallout } from './agentless_available_callout'; +import * as consts from './const'; -const props = { - addAgentLink: '', - onAddAgentClick: jest.fn(), -}; +interface MockedConsts { + AGENTLESS_LEARN_MORE_LINK: string | null; +} +jest.mock('./const'); describe('AgentlessAvailableCallout', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(consts).AGENTLESS_LEARN_MORE_LINK = 'https://www.elastic.co'; + }); + + it('returns null if AGENTLESS_LEARN_MORE_LINK is null', () => { + jest.mocked(consts).AGENTLESS_LEARN_MORE_LINK = null; + + const { container } = render(, { + wrapper: TestProviders, + }); + expect(container).toBeEmptyDOMElement(); + }); + it('renders the agentless available text', () => { - const { getByText, getByTestId } = render(, { + const { getByText, getByTestId } = render(, { wrapper: TestProviders, }); expect(getByText('NEW')).toBeInTheDocument(); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.tsx index 87b4ddf340051..733ba584f252b 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.tsx @@ -12,58 +12,60 @@ import { css } from '@emotion/react'; import { LinkAnchor } from '../../../../../common/components/links'; import { CardCallOut } from '../common/card_callout'; +import { AGENTLESS_LEARN_MORE_LINK } from './const'; -export const AgentlessAvailableCallout = React.memo( - ({ addAgentLink, onAddAgentClick }: { addAgentLink: string; onAddAgentClick: () => void }) => { - const { euiTheme } = useEuiTheme(); +export const AgentlessAvailableCallout = React.memo(() => { + const { euiTheme } = useEuiTheme(); - return ( - , - new: ( - - - - ), - text: ( + if (!AGENTLESS_LEARN_MORE_LINK) { + return null; + } + + return ( + , + new: ( + - ), - link: ( - - - - ), - }} - /> - } - /> - ); - } -); + + ), + text: ( + + ), + link: ( + + + + ), + }} + /> + } + /> + ); +}); AgentlessAvailableCallout.displayName = 'AgentlessAvailableCallout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts index 1cb1cf556c3a6..0dcfde693f81d 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts @@ -8,18 +8,21 @@ import type { CategoryFacet } from '@kbn/fleet-plugin/public'; import type { Tab } from './types'; -export const ONBOARDING_LINK = 'onboardingLink'; +export const ADD_AGENT_PATH = `/agents`; +export const AGENT_INDEX = `logs-elastic_agent*`; +export const AGENTLESS_LEARN_MORE_LINK = null; // Link to be confirmed. +export const CARD_DESCRIPTION_LINE_CLAMP = 3; // 3 lines of text +export const CARD_TITLE_LINE_CLAMP = 1; // 1 line of text +export const FLEET_APP_ID = `fleet`; +export const INTEGRATION_APP_ID = `integrations`; +export const LOADING_SKELETON_HEIGHT = 10; // 10 lines of text +export const MAX_CARD_HEIGHT = 127; // px export const ONBOARDING_APP_ID = 'onboardingAppId'; -export const SEARCH_FILTER_CATEGORIES: CategoryFacet[] = []; +export const ONBOARDING_LINK = 'onboardingLink'; export const SCROLL_ELEMENT_ID = 'integrations-scroll-container'; +export const SEARCH_FILTER_CATEGORIES: CategoryFacet[] = []; export const WITH_SEARCH_BOX_HEIGHT = '517px'; export const WITHOUT_SEARCH_BOX_HEIGHT = '462px'; -export const MAX_CARD_HEIGHT = 127; // px -export const CARD_TITLE_LINE_CLAMP = 1; // 1 line of text -export const CARD_DESCRIPTION_LINE_CLAMP = 3; // 3 lines of text -export const LOADING_SKELETON_HEIGHT = 10; // 10 lines of text -export const AGENT_INDEX = `logs-elastic_agent*`; -export const INTEGRATION_APP_ID = `integrations`; export const INTEGRATION_TABS: Tab[] = [ { category: 'security', diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx index 0dc18a3323234..4157a0e068862 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx @@ -5,30 +5,26 @@ * 2.0. */ import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { IntegrationsCard } from './integrations_card'; -import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; -import { useNavigation } from '../../../../../common/lib/kibana'; import { of } from 'rxjs'; import { useOnboardingService } from '../../../../hooks/use_onboarding_service'; -import { TestProviders } from '../../../../../common/mock/test_providers'; -jest.mock('../../../../../common/hooks/use_add_integrations_url'); -jest.mock('../../../../../common/lib/kibana'); -jest.mock('./available_packages', () => ({ - AvailablePackages: () =>
, -})); jest.mock('../../../../hooks/use_onboarding_service', () => ({ useOnboardingService: jest.fn(), })); -const mockGetAppUrl = jest.fn(); -const mockNavigateTo = jest.fn(); -const mockOnAddIntegrationsUrl = jest.fn(); -(useAddIntegrationsUrl as jest.Mock).mockReturnValue({ - href: '/integrations', - onClick: mockOnAddIntegrationsUrl, -}); +jest.mock('./const', () => ({ + AGENTLESS_LEARN_MORE_LINK: 'https://www.elastic.co', +})); + +jest.mock('./available_packages'); +jest.mock('./packages_installed_callout'); +jest.mock('./agentless_available_callout'); + +jest.mock('./const', () => ({ + AGENTLESS_LEARN_MORE_LINK: 'https://www.elastic.co', +})); const props = { setComplete: jest.fn(), @@ -40,10 +36,6 @@ describe('IntegrationsCard', () => { beforeEach(() => { jest.clearAllMocks(); (useOnboardingService as jest.Mock).mockReturnValue({ isAgentlessAvailable$: of(false) }); - (useNavigation as jest.Mock).mockReturnValue({ - getAppUrl: mockGetAppUrl, - navigateTo: mockNavigateTo, - }); }); it('renders the callout and available packages when integrations are Installed', async () => { @@ -52,17 +44,15 @@ describe('IntegrationsCard', () => { agentStillRequired: false, }; - const { getByTestId, getByText } = render( - , - { wrapper: TestProviders } + const { getByTestId } = render( + ); await waitFor(() => { - expect(getByText('3 integrations have been added')).toBeInTheDocument(); - expect(getByTestId('manageIntegrationsLink')).toBeInTheDocument(); + expect(getByTestId('packageInstalledCallout')).toBeInTheDocument(); }); }); - it('renders the agentless available callout and available packages', async () => { + it('renders the agentless available callout', async () => { (useOnboardingService as jest.Mock).mockReturnValue({ isAgentlessAvailable$: of(true) }); const mockMetadata = { @@ -70,72 +60,12 @@ describe('IntegrationsCard', () => { agentStillRequired: false, }; - const { getByTestId, getByText } = render( - , - { wrapper: TestProviders } - ); - - await waitFor(() => { - expect( - getByText( - 'Identify configuration risks in your cloud account with new and simplified agentless configuration' - ) - ).toBeInTheDocument(); - expect(getByTestId('agentlessLearnMoreLink')).toBeInTheDocument(); - }); - }); - - it('renders the warning callout when an agent is still required', async () => { - const mockMetadata = { - integrationsInstalled: 2, - agentStillRequired: true, - }; - - const { getByTestId, getByText } = render( - , - { wrapper: TestProviders } - ); - await waitFor(() => { - expect( - getByText( - 'Elastic Agent is required for one or more of your integrations. Add Elastic Agent' - ) - ).toBeInTheDocument(); - expect(getByTestId('agentLink')).toBeInTheDocument(); - }); - }); - - it('handles navigation to the Fleet app when Add Agent is clicked', async () => { - const mockMetadata = { - integrationsInstalled: 1, - agentStillRequired: true, - }; - - const { getByTestId } = render( - , - { wrapper: TestProviders } - ); - - fireEvent.click(getByTestId('agentLink')); - await waitFor(() => { - expect(mockNavigateTo).toHaveBeenCalledWith({ appId: 'fleet', path: '/agents' }); - }); - }); - - it('handles clicking on the Manage integrations link', async () => { - const mockMetadata = { - integrationsInstalled: 3, - agentStillRequired: false, - }; - const { getByTestId } = render( - , - { wrapper: TestProviders } + ); - fireEvent.click(getByTestId('manageIntegrationsLink')); await waitFor(() => { - expect(mockOnAddIntegrationsUrl).toHaveBeenCalled(); + expect(getByTestId('agentlessAvailableCallout')).toBeInTheDocument(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index 16985fdc2551d..506c74efe503c 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -4,16 +4,16 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useCallback } from 'react'; +import React from 'react'; import { EuiSpacer } from '@elastic/eui'; import { useObservable } from 'react-use'; import type { OnboardingCardComponent } from '../../../../types'; import { OnboardingCardContentPanel } from '../common/card_content_panel'; import { AvailablePackages } from './available_packages'; -import { useNavigation } from '../../../../../common/lib/kibana'; import { useOnboardingService } from '../../../../hooks/use_onboarding_service'; import { AgentlessAvailableCallout } from './agentless_available_callout'; import { PackageInstalledCallout } from './packages_installed_callout'; +import { AGENTLESS_LEARN_MORE_LINK } from './const'; export const IntegrationsCard: OnboardingCardComponent = ({ checkCompleteMetadata, // this is undefined before the first checkComplete call finishes @@ -22,32 +22,20 @@ export const IntegrationsCard: OnboardingCardComponent = ({ const { isAgentlessAvailable$ } = useOnboardingService(); const isAgentlessAvailable = useObservable(isAgentlessAvailable$, undefined); - const { getAppUrl, navigateTo } = useNavigation(); - const addAgentLink = getAppUrl({ appId: 'fleet', path: '/agents' }); - const onAddAgentClick = useCallback(() => { - navigateTo({ appId: 'fleet', path: '/agents' }); // to be confirmed - }, [navigateTo]); + const showAgentlessCallout = + isAgentlessAvailable && AGENTLESS_LEARN_MORE_LINK && integrationsInstalled === 0; + const showInstalledCallout = + integrationsInstalled > 0 || checkCompleteMetadata?.agentStillRequired; + return ( - {isAgentlessAvailable && integrationsInstalled === 0 && ( - <> - - - - )} - {(integrationsInstalled > 0 || checkCompleteMetadata?.agentStillRequired) && ( - <> - - - - )} + <> + {showAgentlessCallout && } + {showInstalledCallout && ( + + )} + {(showAgentlessCallout || showInstalledCallout) && } + ); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts index 54580d816c9b0..8fc754162f6c7 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts @@ -6,8 +6,8 @@ */ import { checkIntegrationsCardComplete } from './integrations_check_complete'; import { installationStatuses } from '@kbn/fleet-plugin/public'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import type { HttpSetup } from '@kbn/core/public'; +import type { StartServices } from '../../../../../types'; + import { lastValueFrom } from 'rxjs'; jest.mock('rxjs', () => ({ @@ -18,21 +18,21 @@ jest.mock('rxjs', () => ({ describe('checkIntegrationsCardComplete', () => { const mockHttpGet: jest.Mock = jest.fn(); const mockSearch: jest.Mock = jest.fn(); + const mockService = { + http: { + get: mockHttpGet, + }, + data: { + search: { + search: mockSearch, + }, + }, + } as unknown as StartServices; beforeEach(() => { jest.clearAllMocks(); }); - const mockDataPlugin = { - search: { - search: mockSearch, - }, - } as unknown as DataPublicPluginStart; - - const httpSetup = { - get: mockHttpGet, - } as unknown as HttpSetup; - it('returns isComplete as false when no packages are installed', async () => { mockHttpGet.mockResolvedValue({ items: [], @@ -44,10 +44,7 @@ describe('checkIntegrationsCardComplete', () => { }, }); - const result = await checkIntegrationsCardComplete({ - data: mockDataPlugin, - http: httpSetup, - }); + const result = await checkIntegrationsCardComplete(mockService); expect(result).toEqual({ isComplete: false, @@ -69,10 +66,7 @@ describe('checkIntegrationsCardComplete', () => { }, }); - const result = await checkIntegrationsCardComplete({ - data: mockDataPlugin, - http: httpSetup, - }); + const result = await checkIntegrationsCardComplete(mockService); expect(result).toEqual({ isComplete: true, @@ -98,10 +92,7 @@ describe('checkIntegrationsCardComplete', () => { }, }); - const result = await checkIntegrationsCardComplete({ - data: mockDataPlugin, - http: httpSetup, - }); + const result = await checkIntegrationsCardComplete(mockService); expect(result).toEqual({ isComplete: true, diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts index ca119c3e72d6c..17fda6fe75a1d 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts @@ -6,7 +6,7 @@ */ import type { GetPackagesResponse } from '@kbn/fleet-plugin/public'; -import { EPM_PACKAGES_MANY, installationStatuses } from '@kbn/fleet-plugin/public'; +import { EPM_API_ROUTES, installationStatuses } from '@kbn/fleet-plugin/public'; import { i18n } from '@kbn/i18n'; import { lastValueFrom } from 'rxjs'; import type { OnboardingCardCheckComplete } from '../../../../types'; @@ -16,9 +16,12 @@ import type { StartServices } from '../../../../../types'; export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async ( services: StartServices ) => { - const packageData = await services.http.get(EPM_PACKAGES_MANY, { - version: '2023-10-31', - }); + const packageData = await services.http.get( + EPM_API_ROUTES.INSTALL_BY_UPLOAD_PATTERN, + { + version: '2023-10-31', + } + ); const agentsData = await lastValueFrom( services.data.search.search({ diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx index 654972f6a6d61..093e4b5959948 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx @@ -11,21 +11,16 @@ import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integ import { TestProviders } from '../../../../../common/mock/test_providers'; jest.mock('../../../../../common/hooks/use_add_integrations_url'); - +jest.mock('./agent_required_callout'); const mockOnAddIntegrationsUrl = jest.fn(); -(useAddIntegrationsUrl as jest.Mock).mockReturnValue({ - href: '/integrations', - onClick: mockOnAddIntegrationsUrl, -}); - -const props = { - addAgentLink: '', - onAddAgentClick: jest.fn(), -}; describe('PackageInstalledCallout', () => { beforeEach(() => { jest.clearAllMocks(); + (useAddIntegrationsUrl as jest.Mock).mockReturnValue({ + href: '/integrations', + onClick: mockOnAddIntegrationsUrl, + }); }); it('renders the callout and available packages when integrations are installed', () => { @@ -35,7 +30,7 @@ describe('PackageInstalledCallout', () => { }; const { getByTestId, getByText } = render( - , + , { wrapper: TestProviders } ); @@ -49,15 +44,12 @@ describe('PackageInstalledCallout', () => { agentStillRequired: true, }; - const { getByTestId, getByText } = render( - , + const { getByTestId } = render( + , { wrapper: TestProviders } ); - expect( - getByText('Elastic Agent is required for one or more of your integrations. Add Elastic Agent') - ).toBeInTheDocument(); - expect(getByTestId('agentLink')).toBeInTheDocument(); + expect(getByTestId('agentRequiredCallout')).toBeInTheDocument(); }); it('handles clicking on the Manage integrations link', () => { @@ -67,7 +59,7 @@ describe('PackageInstalledCallout', () => { }; const { getByTestId } = render( - , + , { wrapper: TestProviders } ); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx index 02bd06c91fd1a..b9c10955cf671 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx @@ -11,36 +11,30 @@ import { EuiIcon } from '@elastic/eui'; import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; import { LinkAnchor } from '../../../../../common/components/links'; import { CardCallOut } from '../common/card_callout'; +import { AgentRequiredCallout } from './agent_required_callout'; export const PackageInstalledCallout = React.memo( - ({ - addAgentLink, - checkCompleteMetadata, - onAddAgentClick, - }: { - addAgentLink: string; - checkCompleteMetadata: Record | undefined; - onAddAgentClick: () => void; - }) => { + ({ checkCompleteMetadata }: { checkCompleteMetadata: Record | undefined }) => { const { href: integrationUrl, onClick: onAddIntegrationClicked } = useAddIntegrationsUrl(); const integrationsInstalled: number = checkCompleteMetadata?.integrationsInstalled as number; - return ( + if (!checkCompleteMetadata?.integrationsInstalled) { + return null; + } + + return checkCompleteMetadata?.agentStillRequired ? ( + + ) : ( - ) : ( + desc: ( ), - desc2: checkCompleteMetadata?.agentStillRequired ? ( - - - - ) : ( + link: ( Date: Fri, 4 Oct 2024 17:59:18 +0100 Subject: [PATCH 26/35] code review --- .../cards/common/card_content_panel.tsx | 9 +- ...out.tsx => integration_card_grid_tabs.tsx} | 2 +- ...ble_packages.tsx => package_list_grid.tsx} | 3 +- .../cards/integrations/available_packages.tsx | 74 -------- .../__mocks__/agent_required_callout.tsx | 0 .../__mocks__/agentless_available_callout.tsx | 0 .../installed_integrations_callout.tsx | 12 ++ .../__mocks__/manage_integrations_callout.tsx | 9 + .../agent_required_callout.test.tsx | 4 +- .../{ => callouts}/agent_required_callout.tsx | 14 +- .../agentless_available_callout.test.tsx | 6 +- .../agentless_available_callout.tsx | 6 +- .../installed_integrations_callout.test.tsx | 39 ++++ .../installed_integrations_callout.tsx | 32 ++++ .../callouts/integration_card_top_callout.tsx | 46 +++++ .../manage_integrations_callout.tsx} | 22 +-- .../integrations/{const.ts => constants.ts} | 48 +---- .../cards/integrations/hooks.test.ts | 171 ------------------ .../cards/integrations/hooks.ts | 54 ------ ...sx => integration_card_grid_tabs.test.tsx} | 32 +--- .../integration_card_grid_tabs.tsx | 39 ++++ .../integrations/integration_tabs_configs.ts | 63 +++++++ .../integrations/integrations_card.test.tsx | 71 -------- .../cards/integrations/integrations_card.tsx | 41 ++--- .../integrations_check_complete.test.ts | 14 +- .../integrations_check_complete.ts | 12 +- .../integrations/package_list_grid.test.tsx | 27 +-- .../cards/integrations/package_list_grid.tsx | 50 ++--- .../packages_installed_callout.test.tsx | 69 ------- .../cards/integrations/types.ts | 17 +- .../use_integration_card_list.test.ts | 79 ++++++++ .../integrations/use_integration_card_list.ts | 135 ++++++++++++++ .../cards/integrations/utils.test.ts | 167 ----------------- .../cards/integrations/utils.ts | 110 +---------- .../onboarding/hooks/use_stored_state.ts | 8 +- 35 files changed, 588 insertions(+), 897 deletions(-) rename x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/{packages_installed_callout.tsx => integration_card_grid_tabs.tsx} (74%) rename x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/{available_packages.tsx => package_list_grid.tsx} (76%) delete mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx rename x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/{ => callouts}/__mocks__/agent_required_callout.tsx (100%) rename x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/{ => callouts}/__mocks__/agentless_available_callout.tsx (100%) create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/installed_integrations_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/manage_integrations_callout.tsx rename x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/{ => callouts}/agent_required_callout.test.tsx (89%) rename x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/{ => callouts}/agent_required_callout.tsx (79%) rename x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/{ => callouts}/agentless_available_callout.test.tsx (90%) rename x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/{ => callouts}/agentless_available_callout.tsx (92%) create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/installed_integrations_callout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/installed_integrations_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx rename x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/{packages_installed_callout.tsx => callouts/manage_integrations_callout.tsx} (68%) rename x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/{const.ts => constants.ts} (57%) delete mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts delete mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts rename x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/{available_packages.test.tsx => integration_card_grid_tabs.test.tsx} (57%) create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts delete mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts delete mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx index cbbe46d5cee60..981a60a648508 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx @@ -6,12 +6,19 @@ */ import React, { type PropsWithChildren } from 'react'; import { EuiPanel, type EuiPanelProps } from '@elastic/eui'; +import { css } from '@emotion/react'; export const OnboardingCardContentPanel = React.memo>( ({ children, ...panelProps }) => { return ( - + {children} diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/packages_installed_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/integration_card_grid_tabs.tsx similarity index 74% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/packages_installed_callout.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/integration_card_grid_tabs.tsx index 922544ad2a260..660d7b881e397 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/packages_installed_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/integration_card_grid_tabs.tsx @@ -7,4 +7,4 @@ import React from 'react'; -export const PackageInstalledCallout = () =>
; +export const IntegrationsCardGridTabs = () =>
; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/available_packages.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/package_list_grid.tsx similarity index 76% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/available_packages.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/package_list_grid.tsx index b05ce569544f3..759dbf78bfb88 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/available_packages.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/package_list_grid.tsx @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import React from 'react'; -export const AvailablePackages = () =>
; +export const PackageListGrid = () =>
; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx deleted file mode 100644 index 79c3fbed2fedf..0000000000000 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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, { useCallback, useState } from 'react'; - -import useAsyncRetry from 'react-use/lib/useAsyncRetry'; -import type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { EuiButton, EuiCallOut, EuiSkeletonText } from '@elastic/eui'; -import { fetchAvailablePackagesHook } from './utils'; -import { PackageListGrid } from './package_list_grid'; -import { LOADING_SKELETON_HEIGHT } from './const'; - -export const AvailablePackages = React.memo(() => { - const [fetchAvailablePackages, setFetchAvailablePackages] = useState(); - - const { error, retry, loading } = useAsyncRetry(async () => { - if (fetchAvailablePackages) { - return; - } - const loadedHook = await fetchAvailablePackagesHook(); - setFetchAvailablePackages(() => { - return loadedHook; - }); - }); - - const onRetry = useCallback(() => { - if (!loading) { - retry(); - } - }, [loading, retry]); - - if (error) { - return ( - -

- -

- - - -
- ); - } - if (loading || !fetchAvailablePackages) { - return ( - - ); - } - return ; -}); - -AvailablePackages.displayName = 'AvailablePackages'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agent_required_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/agent_required_callout.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agent_required_callout.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/agent_required_callout.tsx diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agentless_available_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/agentless_available_callout.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/agentless_available_callout.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/agentless_available_callout.tsx diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/installed_integrations_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/installed_integrations_callout.tsx new file mode 100644 index 0000000000000..eabc4446bcc77 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/installed_integrations_callout.tsx @@ -0,0 +1,12 @@ +/* + * 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 from 'react'; + +export const InstalledIntegrationsCallout = () => ( +
+); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/manage_integrations_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/manage_integrations_callout.tsx new file mode 100644 index 0000000000000..828a49ab69c07 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/manage_integrations_callout.tsx @@ -0,0 +1,9 @@ +/* + * 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 from 'react'; + +export const ManageIntegrationsCallout = () =>
; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.test.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.test.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.test.tsx index 3502c54be0365..dbd0c105d27a1 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.test.tsx @@ -13,9 +13,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import { AgentRequiredCallout } from './agent_required_callout'; -import { TestProviders } from '../../../../../common/mock/test_providers'; +import { TestProviders } from '../../../../../../common/mock/test_providers'; -jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../../../common/lib/kibana'); describe('AgentRequiredCallout', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.tsx similarity index 79% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.tsx index ae0e5d8d9e580..aad22c959bc65 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agent_required_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.tsx @@ -8,16 +8,18 @@ import React, { useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiIcon } from '@elastic/eui'; -import { LinkAnchor } from '../../../../../common/components/links'; -import { CardCallOut } from '../common/card_callout'; -import { useNavigation } from '../../../../../common/lib/kibana'; -import { FLEET_APP_ID, ADD_AGENT_PATH } from './const'; +import { LinkAnchor } from '../../../../../../common/components/links'; +import { CardCallOut } from '../../common/card_callout'; +import { useNavigation } from '../../../../../../common/lib/kibana'; +import { FLEET_APP_ID, ADD_AGENT_PATH } from '../constants'; + +const fleetAgentLinkProps = { appId: FLEET_APP_ID, path: ADD_AGENT_PATH }; export const AgentRequiredCallout = React.memo(() => { const { getAppUrl, navigateTo } = useNavigation(); - const addAgentLink = getAppUrl({ appId: FLEET_APP_ID, path: ADD_AGENT_PATH }); + const addAgentLink = getAppUrl(fleetAgentLinkProps); const onAddAgentClick = useCallback(() => { - navigateTo({ appId: FLEET_APP_ID, path: ADD_AGENT_PATH }); + navigateTo(fleetAgentLinkProps); }, [navigateTo]); return ( diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.test.tsx similarity index 90% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.test.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.test.tsx index f9bf4cd2967bc..03e5fe2bf748b 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.test.tsx @@ -7,14 +7,14 @@ import { render } from '@testing-library/react'; import React from 'react'; -import { TestProviders } from '../../../../../common/mock/test_providers'; +import { TestProviders } from '../../../../../../common/mock/test_providers'; import { AgentlessAvailableCallout } from './agentless_available_callout'; -import * as consts from './const'; +import * as consts from '../constants'; interface MockedConsts { AGENTLESS_LEARN_MORE_LINK: string | null; } -jest.mock('./const'); +jest.mock('../constants'); describe('AgentlessAvailableCallout', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.tsx index 733ba584f252b..c222e70762652 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/agentless_available_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.tsx @@ -10,9 +10,9 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { EuiIcon, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; -import { LinkAnchor } from '../../../../../common/components/links'; -import { CardCallOut } from '../common/card_callout'; -import { AGENTLESS_LEARN_MORE_LINK } from './const'; +import { LinkAnchor } from '../../../../../../common/components/links'; +import { CardCallOut } from '../../common/card_callout'; +import { AGENTLESS_LEARN_MORE_LINK } from '../constants'; export const AgentlessAvailableCallout = React.memo(() => { const { euiTheme } = useEuiTheme(); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/installed_integrations_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/installed_integrations_callout.test.tsx new file mode 100644 index 0000000000000..3c47c24fd63ec --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/installed_integrations_callout.test.tsx @@ -0,0 +1,39 @@ +/* + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { InstalledIntegrationsCallout } from './installed_integrations_callout'; +jest.mock('./agent_required_callout'); +jest.mock('./manage_integrations_callout'); + +describe('InstalledIntegrationsCallout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the callout and available packages when integrations are installed', () => { + const mockMetadata = { + installedIntegrationsCount: 3, + isAgentRequired: false, + }; + + const { getByTestId } = render(); + + expect(getByTestId('manageIntegrationsCallout')).toBeInTheDocument(); + }); + + it('renders the warning callout when an agent is still required', () => { + const mockMetadata = { + installedIntegrationsCount: 2, + isAgentRequired: true, + }; + + const { getByTestId } = render(); + + expect(getByTestId('agentRequiredCallout')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/installed_integrations_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/installed_integrations_callout.tsx new file mode 100644 index 0000000000000..6a82a538e39ad --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/installed_integrations_callout.tsx @@ -0,0 +1,32 @@ +/* + * 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 from 'react'; + +import { AgentRequiredCallout } from './agent_required_callout'; +import { ManageIntegrationsCallout } from './manage_integrations_callout'; + +export const InstalledIntegrationsCallout = React.memo( + ({ + installedIntegrationsCount, + isAgentRequired, + }: { + installedIntegrationsCount: number; + isAgentRequired: boolean; + }) => { + if (!installedIntegrationsCount) { + return null; + } + + return isAgentRequired ? ( + + ) : ( + + ); + } +); + +InstalledIntegrationsCallout.displayName = 'InstalledIntegrationsCallout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx new file mode 100644 index 0000000000000..0d0ca6fc61da1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx @@ -0,0 +1,46 @@ +/* + * 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 from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { useObservable } from 'react-use'; + +import { useOnboardingService } from '../../../../../hooks/use_onboarding_service'; +import { AGENTLESS_LEARN_MORE_LINK } from '../constants'; +import { AgentlessAvailableCallout } from './agentless_available_callout'; +import { InstalledIntegrationsCallout } from './installed_integrations_callout'; + +export const IntegrationCardTopCallout = React.memo( + ({ + installedIntegrationsCount, + isAgentRequired, + }: { + installedIntegrationsCount: number; + isAgentRequired: boolean; + }) => { + const { isAgentlessAvailable$ } = useOnboardingService(); + const isAgentlessAvailable = useObservable(isAgentlessAvailable$, undefined); + const showAgentlessCallout = + isAgentlessAvailable && AGENTLESS_LEARN_MORE_LINK && installedIntegrationsCount === 0; + const showInstalledCallout = installedIntegrationsCount > 0 || isAgentRequired; + + return ( + <> + {showAgentlessCallout && } + {showInstalledCallout && ( + + )} + {(showAgentlessCallout || showInstalledCallout) && } + + ); + } +); + +IntegrationCardTopCallout.displayName = 'IntegrationCardTopCallout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx similarity index 68% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx index b9c10955cf671..f40d9b79f2b79 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx @@ -8,23 +8,19 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiIcon } from '@elastic/eui'; -import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; -import { LinkAnchor } from '../../../../../common/components/links'; -import { CardCallOut } from '../common/card_callout'; -import { AgentRequiredCallout } from './agent_required_callout'; +import { LinkAnchor } from '../../../../../../common/components/links'; +import { CardCallOut } from '../../common/card_callout'; +import { useAddIntegrationsUrl } from '../../../../../../common/hooks/use_add_integrations_url'; -export const PackageInstalledCallout = React.memo( - ({ checkCompleteMetadata }: { checkCompleteMetadata: Record | undefined }) => { +export const ManageIntegrationsCallout = React.memo( + ({ installedIntegrationsCount }: { installedIntegrationsCount: number }) => { const { href: integrationUrl, onClick: onAddIntegrationClicked } = useAddIntegrationsUrl(); - const integrationsInstalled: number = checkCompleteMetadata?.integrationsInstalled as number; - if (!checkCompleteMetadata?.integrationsInstalled) { + if (!installedIntegrationsCount) { return null; } - return checkCompleteMetadata?.agentStillRequired ? ( - - ) : ( + return ( ), link: ( @@ -63,4 +59,4 @@ export const PackageInstalledCallout = React.memo( } ); -PackageInstalledCallout.displayName = 'PackageInstalledCallout'; +ManageIntegrationsCallout.displayName = 'ManageIntegrationsCallout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts similarity index 57% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts index 0dcfde693f81d..84184df1ca685 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/const.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts @@ -6,6 +6,7 @@ */ import type { CategoryFacet } from '@kbn/fleet-plugin/public'; +import { INTEGRATION_TABS } from './integration_tabs_configs'; import type { Tab } from './types'; export const ADD_AGENT_PATH = `/agents`; @@ -23,49 +24,4 @@ export const SCROLL_ELEMENT_ID = 'integrations-scroll-container'; export const SEARCH_FILTER_CATEGORIES: CategoryFacet[] = []; export const WITH_SEARCH_BOX_HEIGHT = '517px'; export const WITHOUT_SEARCH_BOX_HEIGHT = '462px'; -export const INTEGRATION_TABS: Tab[] = [ - { - category: 'security', - iconType: 'starFilled', - id: 'recommended', - label: 'Recommended', - overflow: 'hidden', - showSearchTools: false, - }, - { - category: 'security', - id: 'network', - label: 'Network', - subCategory: 'network_security', - }, - { - category: 'security', - id: 'user', - label: 'User', - subCategory: 'iam', - }, - { - category: 'security', - id: 'endpoint', - label: 'Endpoint', - subCategory: 'edr_xdr', - }, - { - category: 'security', - id: 'cloud', - label: 'Cloud', - subCategory: 'cloudsecurity_cdr', - }, - { - category: 'security', - id: 'threatIntel', - label: 'Threat Intel', - subCategory: 'threat_intel', - }, - { - category: '', - id: 'all', - label: 'All', - }, -]; -export const DEFAULT_TAB = INTEGRATION_TABS[0]; +export const DEFAULT_TAB: Tab = INTEGRATION_TABS[0]; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts deleted file mode 100644 index ca9af25097559..0000000000000 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* - * 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 { renderHook } from '@testing-library/react-hooks'; -import { useIntegrationCardList, useTabMetaData } from './hooks'; -import { useNavigation } from '../../../../../common/lib/kibana'; -import { getFilteredCards } from './utils'; - -jest.mock('../../../../../common/lib/kibana', () => ({ - useNavigation: jest.fn(), -})); - -jest.mock('./utils', () => ({ - getFilteredCards: jest.fn(), -})); - -describe('useIntegrationCardList', () => { - const mockUseNavigation = useNavigation as jest.Mock; - const mockNavigateTo = jest.fn(); - const mockGetAppUrl = jest.fn(); - const mockIntegrationsList = [ - { - id: 'security', - name: 'Security Integration', - description: 'Integration for security monitoring', - categories: ['security'], - icons: [{ src: 'icon_url', type: 'image' }], - integration: 'security', - title: 'Security Integration', - url: '/app/integrations/security', - version: '1.0.0', - }, - ]; - - beforeEach(() => { - mockUseNavigation.mockReturnValue({ - navigateTo: mockNavigateTo, - getAppUrl: mockGetAppUrl, - }); - }); - - it('returns filtered integration cards when customCardNames are not provided', () => { - const mockFilteredCards = { - featuredCards: {}, - integrationCards: mockIntegrationsList, - }; - (getFilteredCards as jest.Mock).mockReturnValue(mockFilteredCards); - - const { result } = renderHook(() => - useIntegrationCardList({ - integrationsList: mockIntegrationsList, - }) - ); - - expect(getFilteredCards).toHaveBeenCalledWith({ - integrationsList: mockIntegrationsList, - customCardNames: undefined, - navigateTo: mockNavigateTo, - getAppUrl: mockGetAppUrl, - }); - expect(result.current).toEqual(mockFilteredCards.integrationCards); - }); - - it('returns featured cards when customCardNames are provided', () => { - const customCardNames = ['Security Integration']; - const mockFilteredCards = { - featuredCards: { - 'Security Integration': mockIntegrationsList[0], - }, - integrationCards: mockIntegrationsList, - }; - (getFilteredCards as jest.Mock).mockReturnValue(mockFilteredCards); - - const { result } = renderHook(() => - useIntegrationCardList({ - integrationsList: mockIntegrationsList, - customCardNames, - }) - ); - - expect(getFilteredCards).toHaveBeenCalledWith({ - integrationsList: mockIntegrationsList, - customCardNames, - navigateTo: mockNavigateTo, - getAppUrl: mockGetAppUrl, - }); - expect(result.current).toEqual([mockFilteredCards.featuredCards['Security Integration']]); - }); -}); - -describe.each([ - { - id: 'recommended', - expected: { - customCardNames: undefined, - showSearchTools: false, - selectedCategory: 'security', - selectedSubCategory: undefined, - overflow: 'hidden', - }, - }, - { - id: 'network', - expected: { - customCardNames: undefined, - showSearchTools: true, - selectedCategory: 'security', - selectedSubCategory: 'network_security', - overflow: 'scroll', - }, - }, - { - id: 'user', - expected: { - customCardNames: undefined, - showSearchTools: true, - selectedCategory: 'security', - selectedSubCategory: 'iam', - overflow: 'scroll', - }, - }, - { - id: 'endpoint', - expected: { - customCardNames: undefined, - showSearchTools: true, - selectedCategory: 'security', - selectedSubCategory: 'edr_xdr', - overflow: 'scroll', - }, - }, - { - id: 'cloud', - expected: { - customCardNames: undefined, - showSearchTools: true, - selectedCategory: 'security', - selectedSubCategory: 'cloudsecurity_cdr', - overflow: 'scroll', - }, - }, - { - id: 'threatIntel', - expected: { - customCardNames: undefined, - showSearchTools: true, - selectedCategory: 'security', - selectedSubCategory: 'threat_intel', - overflow: 'scroll', - }, - }, - { - id: 'all', - expected: { - customCardNames: undefined, - showSearchTools: true, - selectedCategory: '', - selectedSubCategory: undefined, - overflow: 'scroll', - }, - }, -])('useTabMetaData', ({ id, expected }) => { - it(`returns correct metadata for the ${id} tab`, () => { - const { result } = renderHook(() => useTabMetaData(id)); - - expect(result.current).toEqual(expected); - }); -}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts deleted file mode 100644 index 332f5bd692e79..0000000000000 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/hooks.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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 { useMemo } from 'react'; -import type { IntegrationCardItem } from '@kbn/fleet-plugin/public'; - -import { useNavigation } from '../../../../../common/lib/kibana'; -import { getFilteredCards } from './utils'; -import { INTEGRATION_TABS } from './const'; - -export const useIntegrationCardList = ({ - integrationsList, - customCardNames, -}: { - integrationsList: IntegrationCardItem[]; - customCardNames?: string[] | undefined; -}): IntegrationCardItem[] => { - const { navigateTo, getAppUrl } = useNavigation(); - const { featuredCards, integrationCards } = useMemo( - () => getFilteredCards({ navigateTo, getAppUrl, integrationsList, customCardNames }), - [navigateTo, getAppUrl, integrationsList, customCardNames] - ); - - if (customCardNames && customCardNames.length > 0) { - return Object.values(featuredCards) ?? []; - } - return integrationCards ?? []; -}; - -export const useTabMetaData = (toggleIdSelected: string) => { - const selectedTab = useMemo( - () => INTEGRATION_TABS.find(({ id }) => id === toggleIdSelected), - [toggleIdSelected] - ); - const selectedCategory = selectedTab?.category ?? ''; - const selectedSubCategory = selectedTab?.subCategory; - const showSearchTools = selectedTab?.showSearchTools ?? true; - const customCardNames = useMemo(() => selectedTab?.customCardNames, [selectedTab]); - const overflow = selectedTab?.overflow ?? 'scroll'; - - return useMemo(() => { - return { - showSearchTools, - customCardNames, - selectedCategory, - selectedSubCategory, - overflow, - }; - }, [showSearchTools, customCardNames, selectedCategory, selectedSubCategory, overflow]); -}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.test.tsx similarity index 57% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.test.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.test.tsx index 35692a87cf4a4..877c504f4a1e7 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/available_packages.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react'; -import { AvailablePackages } from './available_packages'; +import { render, waitFor } from '@testing-library/react'; +import { IntegrationsCardGridTabs } from './integration_card_grid_tabs'; import { useAsyncRetry } from 'react-use'; import { fetchAvailablePackagesHook } from './utils'; import { TestProviders } from '../../../../../common/mock/test_providers'; @@ -15,11 +15,9 @@ jest.mock('react-use/lib/useAsyncRetry'); jest.mock('./utils', () => ({ fetchAvailablePackagesHook: jest.fn(), })); -jest.mock('./package_list_grid', () => ({ - PackageListGrid: jest.fn(() =>
), -})); +jest.mock('./package_list_grid'); -describe('AvailablePackages', () => { +describe('IntegrationsCardGridTabs', () => { const mockRetry = jest.fn(); beforeEach(() => { @@ -33,26 +31,10 @@ describe('AvailablePackages', () => { loading: true, }); - const { getByTestId } = render(, { wrapper: TestProviders }); + const { getByTestId } = render(, { wrapper: TestProviders }); expect(getByTestId('loadingPackages')).toBeInTheDocument(); }); - it('shows error callout when there is an error loading data', () => { - (useAsyncRetry as jest.Mock).mockReturnValue({ - error: new Error('Loading error'), - retry: mockRetry, - loading: false, - }); - - const { getByTestId } = render(, { wrapper: TestProviders }); - - const retryButton = getByTestId('retryButton'); - expect(retryButton).toBeInTheDocument(); - - fireEvent.click(retryButton); - expect(mockRetry).toHaveBeenCalled(); - }); - it('renders PackageListGrid when data is loaded successfully', async () => { const mockAvailablePackages = jest.fn(); (fetchAvailablePackagesHook as jest.Mock).mockResolvedValue(mockAvailablePackages); @@ -66,10 +48,10 @@ describe('AvailablePackages', () => { }; }); - const { getByTestId } = render(, { wrapper: TestProviders }); + const { getByTestId } = render(, { wrapper: TestProviders }); await waitFor(() => { - expect(getByTestId('package-list-grid')).toBeInTheDocument(); + expect(getByTestId('packageListGrid')).toBeInTheDocument(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx new file mode 100644 index 0000000000000..841211763bfd6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx @@ -0,0 +1,39 @@ +/* + * 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, { useEffect, useState } from 'react'; +import type { PropsWithChildren } from 'react'; +import { EuiSkeletonText } from '@elastic/eui'; +import { PackageListGrid } from './package_list_grid'; +import { LOADING_SKELETON_HEIGHT } from './constants'; + +export const withLazyHook = ( + Component: React.ComponentType>, + moduleImport: () => Promise

, + fallback: React.ReactNode = null +) => { + return React.memo(function WithLazy(props: D) { + const [lazyModuleProp, setLazyModuleProp] = useState

(); + + useEffect(() => { + moduleImport().then((module) => { + setLazyModuleProp(() => module); + }); + }, []); + + return lazyModuleProp ? : <>{fallback}; + }); +}; + +export const IntegrationsCardGridTabs = withLazyHook( + PackageListGrid, + () => import('@kbn/fleet-plugin/public').then((module) => module.AvailablePackagesHook()), + +); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts new file mode 100644 index 0000000000000..0eb2786d9c283 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts @@ -0,0 +1,63 @@ +/* + * 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 { IntegrationTabId, type Tab } from './types'; + +export const INTEGRATION_TABS: Tab[] = [ + { + category: 'security', + iconType: 'starFilled', + id: IntegrationTabId.recommended, + label: 'Recommended', + overflow: 'hidden', + }, + { + category: 'security', + id: IntegrationTabId.network, + label: 'Network', + subCategory: 'network_security', + showSearchTools: true, + }, + { + category: 'security', + id: IntegrationTabId.user, + label: 'User', + subCategory: 'iam', + showSearchTools: true, + }, + { + category: 'security', + id: IntegrationTabId.endpoint, + label: 'Endpoint', + subCategory: 'edr_xdr', + showSearchTools: true, + }, + { + category: 'security', + id: IntegrationTabId.cloud, + label: 'Cloud', + subCategory: 'cloudsecurity_cdr', + showSearchTools: true, + }, + { + category: 'security', + id: IntegrationTabId.threatIntel, + label: 'Threat Intel', + subCategory: 'threat_intel', + showSearchTools: true, + }, + { + category: '', + id: IntegrationTabId.all, + label: 'All', + showSearchTools: true, + }, +]; + +export const INTEGRATION_TABS_BY_ID = Object.fromEntries( + INTEGRATION_TABS.map((tab) => [tab.id, tab]) +) as Record; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx deleted file mode 100644 index 4157a0e068862..0000000000000 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 from 'react'; -import { render, waitFor } from '@testing-library/react'; -import { IntegrationsCard } from './integrations_card'; -import { of } from 'rxjs'; -import { useOnboardingService } from '../../../../hooks/use_onboarding_service'; - -jest.mock('../../../../hooks/use_onboarding_service', () => ({ - useOnboardingService: jest.fn(), -})); - -jest.mock('./const', () => ({ - AGENTLESS_LEARN_MORE_LINK: 'https://www.elastic.co', -})); - -jest.mock('./available_packages'); -jest.mock('./packages_installed_callout'); -jest.mock('./agentless_available_callout'); - -jest.mock('./const', () => ({ - AGENTLESS_LEARN_MORE_LINK: 'https://www.elastic.co', -})); - -const props = { - setComplete: jest.fn(), - isCardComplete: jest.fn(), - setExpandedCardId: jest.fn(), -}; - -describe('IntegrationsCard', () => { - beforeEach(() => { - jest.clearAllMocks(); - (useOnboardingService as jest.Mock).mockReturnValue({ isAgentlessAvailable$: of(false) }); - }); - - it('renders the callout and available packages when integrations are Installed', async () => { - const mockMetadata = { - integrationsInstalled: 3, - agentStillRequired: false, - }; - - const { getByTestId } = render( - - ); - await waitFor(() => { - expect(getByTestId('packageInstalledCallout')).toBeInTheDocument(); - }); - }); - - it('renders the agentless available callout', async () => { - (useOnboardingService as jest.Mock).mockReturnValue({ isAgentlessAvailable$: of(true) }); - - const mockMetadata = { - integrationsInstalled: 0, - agentStillRequired: false, - }; - - const { getByTestId } = render( - - ); - - await waitFor(() => { - expect(getByTestId('agentlessAvailableCallout')).toBeInTheDocument(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index 506c74efe503c..623c74f1eb971 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -5,38 +5,33 @@ * 2.0. */ import React from 'react'; -import { EuiSpacer } from '@elastic/eui'; -import { useObservable } from 'react-use'; + import type { OnboardingCardComponent } from '../../../../types'; import { OnboardingCardContentPanel } from '../common/card_content_panel'; -import { AvailablePackages } from './available_packages'; -import { useOnboardingService } from '../../../../hooks/use_onboarding_service'; -import { AgentlessAvailableCallout } from './agentless_available_callout'; -import { PackageInstalledCallout } from './packages_installed_callout'; -import { AGENTLESS_LEARN_MORE_LINK } from './const'; +import { IntegrationsCardGridTabs } from './integration_card_grid_tabs'; +import { CenteredLoadingSpinner } from '../../../../../common/components/centered_loading_spinner'; +import type { IntegrationCardMetadata } from './types'; +import { IntegrationCardTopCallout } from './callouts/integration_card_top_callout'; + +const isCheckCompleteMetadata = (metadata?: unknown): metadata is IntegrationCardMetadata => { + return metadata !== undefined; +}; export const IntegrationsCard: OnboardingCardComponent = ({ checkCompleteMetadata, // this is undefined before the first checkComplete call finishes }) => { - const integrationsInstalled: number = checkCompleteMetadata?.integrationsInstalled as number; - - const { isAgentlessAvailable$ } = useOnboardingService(); - const isAgentlessAvailable = useObservable(isAgentlessAvailable$, undefined); - const showAgentlessCallout = - isAgentlessAvailable && AGENTLESS_LEARN_MORE_LINK && integrationsInstalled === 0; - const showInstalledCallout = - integrationsInstalled > 0 || checkCompleteMetadata?.agentStillRequired; + if (!isCheckCompleteMetadata(checkCompleteMetadata)) { + return ; + } + const { installedIntegrationsCount, isAgentRequired } = checkCompleteMetadata; return ( - <> - {showAgentlessCallout && } - {showInstalledCallout && ( - - )} - {(showAgentlessCallout || showInstalledCallout) && } - - + + ); }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts index 8fc754162f6c7..3dd19d8868390 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts @@ -49,8 +49,8 @@ describe('checkIntegrationsCardComplete', () => { expect(result).toEqual({ isComplete: false, metadata: { - integrationsInstalled: 0, - agentStillRequired: false, + installedIntegrationsCount: 0, + isAgentRequired: false, }, }); }); @@ -72,13 +72,13 @@ describe('checkIntegrationsCardComplete', () => { isComplete: true, completeBadgeText: '1 integration added', metadata: { - integrationsInstalled: 1, - agentStillRequired: true, + installedIntegrationsCount: 1, + isAgentRequired: true, }, }); }); - it('returns isComplete as true and agentStillRequired as false when both packages and agent data are available', async () => { + it('returns isComplete as true and isAgentRequired as false when both packages and agent data are available', async () => { mockHttpGet.mockResolvedValue({ items: [ { status: installationStatuses.Installed }, @@ -98,8 +98,8 @@ describe('checkIntegrationsCardComplete', () => { isComplete: true, completeBadgeText: '2 integrations added', metadata: { - integrationsInstalled: 2, - agentStillRequired: false, + installedIntegrationsCount: 2, + isAgentRequired: false, }, }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts index 17fda6fe75a1d..80f48ca41d97f 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.ts @@ -10,7 +10,7 @@ import { EPM_API_ROUTES, installationStatuses } from '@kbn/fleet-plugin/public'; import { i18n } from '@kbn/i18n'; import { lastValueFrom } from 'rxjs'; import type { OnboardingCardCheckComplete } from '../../../../types'; -import { AGENT_INDEX } from './const'; +import { AGENT_INDEX } from './constants'; import type { StartServices } from '../../../../../types'; export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async ( @@ -36,7 +36,7 @@ export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async ); const isComplete = installed && installed.length > 0; const agentsDataAvailable = !!agentsData?.rawResponse?.hits?.total; - const agentStillRequired = isComplete && !agentsDataAvailable; + const isAgentRequired = isComplete && !agentsDataAvailable; const completeBadgeText = i18n.translate( 'xpack.securitySolution.onboarding.integrationsCard.badge.completeText', @@ -50,8 +50,8 @@ export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async return { isComplete, metadata: { - integrationsInstalled: 0, - agentStillRequired: false, + installedIntegrationsCount: 0, + isAgentRequired: false, }, }; } @@ -60,8 +60,8 @@ export const checkIntegrationsCardComplete: OnboardingCardCheckComplete = async isComplete, completeBadgeText, metadata: { - integrationsInstalled: installed.length, - agentStillRequired, + installedIntegrationsCount: installed.length, + isAgentRequired, }, }; }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx index e753148493310..0f5fca591de0b 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx @@ -11,16 +11,20 @@ import { useStoredIntegrationSearchTerm, useStoredIntegrationTabId, } from '../../../../hooks/use_stored_state'; -import { useIntegrationCardList, useTabMetaData } from './hooks'; import { PackageList } from './utils'; -import { DEFAULT_TAB } from './const'; +import { DEFAULT_TAB } from './constants'; jest.mock('../../../onboarding_context'); jest.mock('../../../../hooks/use_stored_state'); -jest.mock('./hooks'); jest.mock('./utils', () => ({ PackageList: jest.fn(() =>

), })); +jest.mock('../../../../../common/lib/kibana', () => ({ + useNavigation: jest.fn().mockReturnValue({ + navigateTo: jest.fn(), + getAppUrl: jest.fn(), + }), +})); describe('PackageListGrid', () => { const mockUseAvailablePackages = jest.fn(); @@ -34,13 +38,6 @@ describe('PackageListGrid', () => { jest.clearAllMocks(); (useStoredIntegrationTabId as jest.Mock).mockReturnValue([DEFAULT_TAB.id, jest.fn()]); (useStoredIntegrationSearchTerm as jest.Mock).mockReturnValue(['', jest.fn()]); - (useTabMetaData as jest.Mock).mockReturnValue({ - showSearchTools: true, - customCardNames: {}, - selectedCategory: 'security', - selectedSubCategory: 'network', - }); - (useIntegrationCardList as jest.Mock).mockReturnValue([]); }); it('renders loading skeleton when data is loading', () => { @@ -62,7 +59,7 @@ describe('PackageListGrid', () => { it('renders the package list when data is available', () => { mockUseAvailablePackages.mockReturnValue({ isLoading: false, - filteredCards: [{ id: 'card1', name: 'Card 1' }], + filteredCards: [{ id: 'card1', name: 'Card 1', url: 'https://mock-url' }], setCategory: mockSetCategory, setSelectedSubCategory: mockSetSelectedSubCategory, setSearchTerm: mockSetSearchTerm, @@ -97,14 +94,6 @@ describe('PackageListGrid', () => { }); it('renders no search tools when showSearchTools is false', () => { - (useTabMetaData as jest.Mock).mockReturnValue({ - showSearchTools: false, - customCardNames: {}, - selectedCategory: 'category1', - selectedSubCategory: 'subcategory1', - overflow: 'auto', - }); - mockUseAvailablePackages.mockReturnValue({ isLoading: false, filteredCards: [], diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx index 0a0305b58e45c..a68c6d1babbf6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react'; +import React, { Suspense, useMemo, useCallback, useEffect, useRef, useState } from 'react'; import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiSkeletonText } from '@elastic/eui'; import type { AvailablePackagesHookType, IntegrationCardItem } from '@kbn/fleet-plugin/public'; @@ -13,7 +13,6 @@ import { noop } from 'lodash'; import { css } from '@emotion/react'; import { PackageList } from './utils'; -import { useIntegrationCardList, useTabMetaData } from './hooks'; import { useStoredIntegrationSearchTerm, useStoredIntegrationTabId, @@ -21,18 +20,24 @@ import { import { useOnboardingContext } from '../../../onboarding_context'; import { DEFAULT_TAB, - INTEGRATION_TABS, LOADING_SKELETON_HEIGHT, SCROLL_ELEMENT_ID, SEARCH_FILTER_CATEGORIES, WITHOUT_SEARCH_BOX_HEIGHT, WITH_SEARCH_BOX_HEIGHT, -} from './const'; +} from './constants'; +import { INTEGRATION_TABS, INTEGRATION_TABS_BY_ID } from './integration_tabs_configs'; +import { useIntegrationCardList } from './use_integration_card_list'; +import type { IntegrationTabId } from './types'; interface WrapperProps { useAvailablePackages: AvailablePackagesHookType; } +const isIntegrationTabId = (id: string): id is IntegrationTabId => { + return Object.keys(INTEGRATION_TABS_BY_ID).includes(id); +}; + const emptyStateStyles = { paddingTop: '16px' }; export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProps) => { const { spaceId } = useOnboardingContext(); @@ -41,10 +46,13 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp spaceId, DEFAULT_TAB.id ); + const [toggleIdSelected, setToggleIdSelected] = useState(selectedTabId); const [searchTermFromStorage, setSearchTermToStorage] = useStoredIntegrationSearchTerm(spaceId); - const [toggleIdSelected, setToggleIdSelected] = useState(selectedTabId); const onTabChange = useCallback( (id: string) => { + if (!isIntegrationTabId(id)) { + return; + } scrollElement.current?.scrollTo?.(0, 0); setToggleIdSelected(id); setSelectedTabIdToStorage(id); @@ -63,8 +71,7 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp prereleaseIntegrationsEnabled: false, }); - const { showSearchTools, customCardNames, selectedCategory, selectedSubCategory, overflow } = - useTabMetaData(toggleIdSelected); + const selectedTab = useMemo(() => INTEGRATION_TABS_BY_ID[selectedTabId], [selectedTabId]); const onSearchTermChanged = useCallback( (searchQuery: string) => { @@ -75,31 +82,30 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp ); useEffect(() => { - setCategory(selectedCategory); - setSelectedSubCategory(selectedSubCategory); - if (!showSearchTools) { + setCategory(selectedTab.category ?? ''); + setSelectedSubCategory(selectedTab.subCategory); + if (!selectedTab.showSearchTools) { // If search box are not shown, clear the search term to avoid unexpected filtering onSearchTermChanged(''); } - if (showSearchTools && searchTermFromStorage) { + if (selectedTab.showSearchTools && searchTermFromStorage) { setSearchTerm(searchTermFromStorage); } }, [ onSearchTermChanged, searchTermFromStorage, - selectedCategory, - selectedSubCategory, + selectedTab.category, + selectedTab.showSearchTools, + selectedTab.subCategory, setCategory, setSearchTerm, setSelectedSubCategory, - showSearchTools, - toggleIdSelected, ]); const list: IntegrationCardItem[] = useIntegrationCardList({ integrationsList: filteredCards, - customCardNames, + customCardNames: selectedTab.customCardNames, }); if (isLoading) { @@ -116,9 +122,9 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp @@ -135,7 +141,7 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx deleted file mode 100644 index 093e4b5959948..0000000000000 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/packages_installed_callout.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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 from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { PackageInstalledCallout } from './packages_installed_callout'; -import { useAddIntegrationsUrl } from '../../../../../common/hooks/use_add_integrations_url'; -import { TestProviders } from '../../../../../common/mock/test_providers'; - -jest.mock('../../../../../common/hooks/use_add_integrations_url'); -jest.mock('./agent_required_callout'); -const mockOnAddIntegrationsUrl = jest.fn(); - -describe('PackageInstalledCallout', () => { - beforeEach(() => { - jest.clearAllMocks(); - (useAddIntegrationsUrl as jest.Mock).mockReturnValue({ - href: '/integrations', - onClick: mockOnAddIntegrationsUrl, - }); - }); - - it('renders the callout and available packages when integrations are installed', () => { - const mockMetadata = { - integrationsInstalled: 3, - agentStillRequired: false, - }; - - const { getByTestId, getByText } = render( - , - { wrapper: TestProviders } - ); - - expect(getByText('3 integrations have been added')).toBeInTheDocument(); - expect(getByTestId('manageIntegrationsLink')).toBeInTheDocument(); - }); - - it('renders the warning callout when an agent is still required', () => { - const mockMetadata = { - integrationsInstalled: 2, - agentStillRequired: true, - }; - - const { getByTestId } = render( - , - { wrapper: TestProviders } - ); - - expect(getByTestId('agentRequiredCallout')).toBeInTheDocument(); - }); - - it('handles clicking on the Manage integrations link', () => { - const mockMetadata = { - integrationsInstalled: 3, - agentStillRequired: false, - }; - - const { getByTestId } = render( - , - { wrapper: TestProviders } - ); - - fireEvent.click(getByTestId('manageIntegrationsLink')); - expect(mockOnAddIntegrationsUrl).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts index 40c23d7628f7d..f18818c039115 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts @@ -8,9 +8,24 @@ export interface Tab { category: string; customCardNames?: string[]; // custom card name e.g.: 1password iconType?: string; - id: string; + id: IntegrationTabId; label: string; overflow?: 'hidden' | 'scroll'; showSearchTools?: boolean; subCategory?: string; } + +export enum IntegrationTabId { + recommended = 'recommended', + network = 'network', + user = 'user', + endpoint = 'endpoint', + cloud = 'cloud', + threatIntel = 'threatIntel', + all = 'all', +} + +export interface IntegrationCardMetadata { + installedIntegrationsCount: number; + isAgentRequired: boolean; +} diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts new file mode 100644 index 0000000000000..424c3cace11ec --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useIntegrationCardList } from './use_integration_card_list'; +import { useNavigation } from '../../../../../common/lib/kibana'; + +jest.mock('../../../../../common/lib/kibana', () => ({ + useNavigation: jest.fn(), +})); + +describe('useIntegrationCardList', () => { + const mockUseNavigation = useNavigation as jest.Mock; + const mockNavigateTo = jest.fn(); + const mockGetAppUrl = jest.fn(); + const mockIntegrationsList = [ + { + id: 'security', + name: 'Security Integration', + description: 'Integration for security monitoring', + categories: ['security'], + icons: [{ src: 'icon_url', type: 'image' }], + integration: 'security', + maxCardHeight: 127, + onCardClick: expect.any(Function), + showInstallStatus: true, + titleLineClamp: 1, + descriptionLineClamp: 3, + showInstallationStatus: true, + title: 'Security Integration', + url: '/app/integrations/security', + version: '1.0.0', + }, + ]; + + beforeEach(() => { + mockUseNavigation.mockReturnValue({ + navigateTo: mockNavigateTo, + getAppUrl: mockGetAppUrl, + }); + }); + + it('returns filtered integration cards when customCardNames are not provided', () => { + const mockFilteredCards = { + featuredCards: {}, + integrationCards: mockIntegrationsList, + }; + + const { result } = renderHook(() => + useIntegrationCardList({ + integrationsList: mockIntegrationsList, + }) + ); + + expect(result.current).toEqual(mockFilteredCards.integrationCards); + }); + + it('returns featured cards when customCardNames are provided', () => { + const customCardNames = ['Security Integration']; + const mockFilteredCards = { + featuredCards: { + 'Security Integration': mockIntegrationsList[0], + }, + integrationCards: mockIntegrationsList, + }; + + const { result } = renderHook(() => + useIntegrationCardList({ + integrationsList: mockIntegrationsList, + customCardNames, + }) + ); + + expect(result.current).toEqual([mockFilteredCards.featuredCards['Security Integration']]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts new file mode 100644 index 0000000000000..371f535bc3c7c --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts @@ -0,0 +1,135 @@ +/* + * 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 { useMemo } from 'react'; +import type { IntegrationCardItem } from '@kbn/fleet-plugin/public'; +import { SECURITY_UI_APP_ID } from '@kbn/security-solution-navigation'; +import { useNavigation } from '../../../../../common/lib/kibana'; +import { + APP_INTEGRATIONS_PATH, + APP_UI_ID, + ONBOARDING_PATH, +} from '../../../../../../common/constants'; +import { + CARD_DESCRIPTION_LINE_CLAMP, + CARD_TITLE_LINE_CLAMP, + INTEGRATION_APP_ID, + MAX_CARD_HEIGHT, + ONBOARDING_APP_ID, + ONBOARDING_LINK, +} from './constants'; +import type { GetAppUrl, NavigateTo } from '../../../../../common/lib/kibana'; + +const addPathParamToUrl = (url: string, onboardingLink: string) => { + const encoded = encodeURIComponent(onboardingLink); + const paramsString = `${ONBOARDING_LINK}=${encoded}&${ONBOARDING_APP_ID}=${APP_UI_ID}`; + + if (url.indexOf('?') >= 0) { + return `${url}&${paramsString}`; + } + return `${url}?${paramsString}`; +}; + +const extractFeaturedCards = ( + filteredCards: IntegrationCardItem[], + featuredCardNames?: string[] +) => { + return filteredCards.reduce((acc: Record, card) => { + if (featuredCardNames?.includes(card.name)) { + acc[card.name] = card; + } + return acc; + }, {}); +}; + +const getFilteredCards = ({ + customCardNames, + getAppUrl, + installedIntegrationList, + integrationsList, + navigateTo, +}: { + customCardNames?: string[]; + getAppUrl: GetAppUrl; + installedIntegrationList?: IntegrationCardItem[]; + integrationsList: IntegrationCardItem[]; + navigateTo: NavigateTo; +}) => { + const securityIntegrationsList = integrationsList.map((card) => + addSecuritySpecificProps({ navigateTo, getAppUrl, card, installedIntegrationList }) + ); + if (!customCardNames) { + return { featuredCards: {}, integrationCards: securityIntegrationsList }; + } + + return { + featuredCards: extractFeaturedCards(securityIntegrationsList, customCardNames), + integrationCards: securityIntegrationsList, + }; +}; + +const addSecuritySpecificProps = ({ + navigateTo, + getAppUrl, + card, +}: { + navigateTo: NavigateTo; + getAppUrl: GetAppUrl; + card: IntegrationCardItem; + installedIntegrationList?: IntegrationCardItem[]; +}): IntegrationCardItem => { + const onboardingLink = getAppUrl({ appId: SECURITY_UI_APP_ID, path: ONBOARDING_PATH }); + const integrationRootUrl = getAppUrl({ appId: INTEGRATION_APP_ID }); + const state = { + onCancelNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], + onCancelUrl: onboardingLink, + onSaveNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], + }; + const url = + card.url.indexOf(APP_INTEGRATIONS_PATH) >= 0 && onboardingLink + ? addPathParamToUrl(card.url, onboardingLink) + : card.url; + return { + ...card, + titleLineClamp: CARD_TITLE_LINE_CLAMP, + descriptionLineClamp: CARD_DESCRIPTION_LINE_CLAMP, + maxCardHeight: MAX_CARD_HEIGHT, + showInstallationStatus: true, + url, + onCardClick: () => { + if (url.startsWith(APP_INTEGRATIONS_PATH)) { + navigateTo({ + appId: INTEGRATION_APP_ID, + path: url.slice(integrationRootUrl.length), + state, + }); + } else if (url.startsWith('http') || url.startsWith('https')) { + window.open(url, '_blank'); + } else { + navigateTo({ url, state }); + } + }, + }; +}; + +export const useIntegrationCardList = ({ + integrationsList, + customCardNames, +}: { + integrationsList: IntegrationCardItem[]; + customCardNames?: string[] | undefined; +}): IntegrationCardItem[] => { + const { navigateTo, getAppUrl } = useNavigation(); + const { featuredCards, integrationCards } = useMemo( + () => getFilteredCards({ navigateTo, getAppUrl, integrationsList, customCardNames }), + [navigateTo, getAppUrl, integrationsList, customCardNames] + ); + + if (customCardNames && customCardNames.length > 0) { + return Object.values(featuredCards) ?? []; + } + return integrationCards ?? []; +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts deleted file mode 100644 index 97d8172b5c8a5..0000000000000 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -/* - * 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 type { IntegrationCardItem } from '@kbn/fleet-plugin/public'; -import { extractFeaturedCards, getFilteredCards } from './utils'; // Update the path accordingly -import { SECURITY_UI_APP_ID } from '@kbn/security-solution-navigation'; -import { INTEGRATION_APP_ID } from './const'; -import { APP_UI_ID, ONBOARDING_PATH } from '../../../../../../common/constants'; - -const maxCardHeight = 127; -const cardTitleLineClamp = 1; -const cardDescriptionLineClamp = 3; -const expectedPath = `?onboardingLink=${encodeURIComponent( - '/app/security/get_started' -)}&onboardingAppId=securitySolutionUI`; -const expectedUrl = `/app/integrations${expectedPath}`; -const mockIntegrationCardItem = { - categories: ['security'], - description: 'Security integration for monitoring.', - icons: [ - { - src: 'icon_url', - type: 'image', - }, - ], - id: 'security-integration', - integration: 'security', - name: 'Security Integration', - title: 'Security Integration', - url: '/app/integrations', - version: '1.0.0', -}; -const mockIntegrationCardItems: IntegrationCardItem[] = [mockIntegrationCardItem]; - -describe('extractFeaturedCards', () => { - it('returns an empty object when no featuredCardNames are provided', () => { - const result = extractFeaturedCards(mockIntegrationCardItems, []); - expect(result).toEqual({}); - }); - - it('extracts featured cards when featuredCardNames are provided', () => { - const featuredNames = ['Security Integration']; - const result = extractFeaturedCards(mockIntegrationCardItems, featuredNames); - - expect(result).toEqual({ - 'Security Integration': mockIntegrationCardItem, - }); - }); - - it('returns an empty object when no matching featured cards are found', () => { - const result = extractFeaturedCards(mockIntegrationCardItems, ['NonExistentCard']); - expect(result).toEqual({}); - }); -}); - -describe('getFilteredCards', () => { - const mockGetAppUrl = jest - .fn() - .mockImplementation(({ appId }) => - appId === SECURITY_UI_APP_ID ? '/app/security/get_started' : '/app/integrations' - ); - const mockNavigateTo = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns integration cards without featured cards when no custom cards are provided', () => { - const result = getFilteredCards({ - integrationsList: mockIntegrationCardItems, - getAppUrl: mockGetAppUrl, - navigateTo: mockNavigateTo, - }); - - expect(result).toEqual({ - featuredCards: {}, - integrationCards: [ - { - ...mockIntegrationCardItems[0], - titleLineClamp: cardTitleLineClamp, - descriptionLineClamp: cardDescriptionLineClamp, - maxCardHeight, - showInstallationStatus: true, - url: expectedUrl, - onCardClick: expect.any(Function), - }, - ], - }); - }); - - it('returns both featured cards and integration cards when custom cards are provided', () => { - const customCards = ['Security Integration']; - const result = getFilteredCards({ - integrationsList: mockIntegrationCardItems, - customCardNames: customCards, - getAppUrl: mockGetAppUrl, - navigateTo: mockNavigateTo, - }); - - expect(result).toEqual({ - featuredCards: { - 'Security Integration': { - ...mockIntegrationCardItems[0], - titleLineClamp: cardTitleLineClamp, - descriptionLineClamp: cardDescriptionLineClamp, - maxCardHeight, - showInstallationStatus: true, - url: expectedUrl, - onCardClick: expect.any(Function), - }, - }, - integrationCards: [ - { - ...mockIntegrationCardItems[0], - titleLineClamp: cardTitleLineClamp, - descriptionLineClamp: cardDescriptionLineClamp, - maxCardHeight, - showInstallationStatus: true, - url: expectedUrl, - onCardClick: expect.any(Function), - }, - ], - }); - }); - - it("should update routes' state when clicking an integration card", () => { - const result = getFilteredCards({ - integrationsList: mockIntegrationCardItems, - getAppUrl: mockGetAppUrl, - navigateTo: mockNavigateTo, - }); - - result.integrationCards[0].onCardClick?.(); - - expect(mockNavigateTo).toHaveBeenCalledWith({ - appId: INTEGRATION_APP_ID, - path: expectedPath, - state: { - onCancelNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], - onCancelUrl: '/app/security/get_started', - onSaveNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], - }, - }); - }); - - it('should handle links do not start with integration path', () => { - const result = getFilteredCards({ - integrationsList: [{ ...mockIntegrationCardItem, url: '/app/home' }], - getAppUrl: mockGetAppUrl, - navigateTo: mockNavigateTo, - }); - - result.integrationCards[0].onCardClick?.(); - - expect(mockNavigateTo).toHaveBeenCalledWith({ - url: '/app/home', - state: { - onCancelNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], - onCancelUrl: '/app/security/get_started', - onSaveNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], - }, - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts index 0e3d79fe77eb5..7abcb19c06b63 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts @@ -6,23 +6,7 @@ */ import { lazy } from 'react'; -import type { AvailablePackagesHookType, IntegrationCardItem } from '@kbn/fleet-plugin/public'; - -import { SECURITY_UI_APP_ID } from '@kbn/security-solution-navigation'; -import { - APP_INTEGRATIONS_PATH, - APP_UI_ID, - ONBOARDING_PATH, -} from '../../../../../../common/constants'; -import { - CARD_DESCRIPTION_LINE_CLAMP, - CARD_TITLE_LINE_CLAMP, - INTEGRATION_APP_ID, - MAX_CARD_HEIGHT, - ONBOARDING_APP_ID, - ONBOARDING_LINK, -} from './const'; -import type { GetAppUrl, NavigateTo } from '../../../../../common/lib/kibana'; +import type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public'; export const PackageList = lazy(async () => ({ default: await import('@kbn/fleet-plugin/public') @@ -34,95 +18,3 @@ export const fetchAvailablePackagesHook = (): Promise import('@kbn/fleet-plugin/public') .then((module) => module.AvailablePackagesHook()) .then((hook) => hook.useAvailablePackages); - -export const extractFeaturedCards = ( - filteredCards: IntegrationCardItem[], - featuredCardNames?: string[] -) => { - return filteredCards.reduce((acc: Record, card) => { - if (featuredCardNames?.includes(card.name)) { - acc[card.name] = card; - } - return acc; - }, {}); -}; - -export const getFilteredCards = ({ - customCardNames, - getAppUrl, - installedIntegrationList, - integrationsList, - navigateTo, -}: { - customCardNames?: string[]; - getAppUrl: GetAppUrl; - installedIntegrationList?: IntegrationCardItem[]; - integrationsList: IntegrationCardItem[]; - navigateTo: NavigateTo; -}) => { - const securityIntegrationsList = integrationsList.map((card) => - addSecuritySpecificProps({ navigateTo, getAppUrl, card, installedIntegrationList }) - ); - if (!customCardNames) { - return { featuredCards: {}, integrationCards: securityIntegrationsList }; - } - - return { - featuredCards: extractFeaturedCards(securityIntegrationsList, customCardNames), - integrationCards: securityIntegrationsList, - }; -}; - -const addPathParamToUrl = (url: string, onboardingLink: string) => { - const encoded = encodeURIComponent(onboardingLink); - const paramsString = `${ONBOARDING_LINK}=${encoded}&${ONBOARDING_APP_ID}=${APP_UI_ID}`; - - if (url.indexOf('?') >= 0) { - return `${url}&${paramsString}`; - } - return `${url}?${paramsString}`; -}; - -const addSecuritySpecificProps = ({ - navigateTo, - getAppUrl, - card, -}: { - navigateTo: NavigateTo; - getAppUrl: GetAppUrl; - card: IntegrationCardItem; - installedIntegrationList?: IntegrationCardItem[]; -}): IntegrationCardItem => { - const onboardingLink = getAppUrl({ appId: SECURITY_UI_APP_ID, path: ONBOARDING_PATH }); - const integrationRootUrl = getAppUrl({ appId: INTEGRATION_APP_ID }); - const state = { - onCancelNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], - onCancelUrl: onboardingLink, - onSaveNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }], - }; - const url = - card.url.indexOf(APP_INTEGRATIONS_PATH) >= 0 && onboardingLink - ? addPathParamToUrl(card.url, onboardingLink) - : card.url; - return { - ...card, - titleLineClamp: CARD_TITLE_LINE_CLAMP, - descriptionLineClamp: CARD_DESCRIPTION_LINE_CLAMP, - maxCardHeight: MAX_CARD_HEIGHT, - showInstallationStatus: true, - url, - onCardClick: () => { - if (url.startsWith(APP_INTEGRATIONS_PATH)) { - navigateTo({ - appId: INTEGRATION_APP_ID, - path: url.slice(integrationRootUrl.length), - state, - }); - } else if (url.startsWith('http') || url.startsWith('https')) { - window.open(url, '_blank'); - } else { - navigateTo({ url, state }); - } - }, - }; -}; diff --git a/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts b/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts index 47ad13a75c4c3..c22c8f0f5310c 100644 --- a/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts +++ b/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts @@ -7,6 +7,7 @@ import { useLocalStorage } from 'react-use'; import type { OnboardingCardId } from '../constants'; +import type { IntegrationTabId } from '../components/onboarding_body/cards/integrations/types'; const LocalStorageKey = { avcBannerDismissed: 'ONBOARDING_HUB.AVC_BANNER_DISMISSED', @@ -50,8 +51,11 @@ export const useStoredExpandedCardId = (spaceId: string) => /** * Stores the selected integration tab ID per space */ -export const useStoredIntegrationTabId = (spaceId: string, defaultSelectedTabId: string) => - useDefinedLocalStorage( +export const useStoredIntegrationTabId = ( + spaceId: string, + defaultSelectedTabId: IntegrationTabId +) => + useDefinedLocalStorage( `${LocalStorageKey.selectedIntegrationTabId}.${spaceId}`, defaultSelectedTabId ); From 1be3165bfa53a376e2f13a1ac6e1961552e6b680 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 7 Oct 2024 12:22:17 +0100 Subject: [PATCH 27/35] search term behaviour --- .../integrations/integration_tabs_configs.ts | 1 + .../integrations/package_list_grid.test.tsx | 5 +++- .../cards/integrations/package_list_grid.tsx | 26 +++++++++++++------ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts index 0eb2786d9c283..bd8603522cbd6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts @@ -14,6 +14,7 @@ export const INTEGRATION_TABS: Tab[] = [ id: IntegrationTabId.recommended, label: 'Recommended', overflow: 'hidden', + showSearchTools: false, }, { category: 'security', diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx index 0f5fca591de0b..9617483f1ac5b 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx @@ -109,7 +109,10 @@ describe('PackageListGrid', () => { it('updates the search term when the search input changes', async () => { const mockSetSearchTermToStorage = jest.fn(); - (useStoredIntegrationSearchTerm as jest.Mock).mockReturnValue(['', mockSetSearchTermToStorage]); + (useStoredIntegrationSearchTerm as jest.Mock).mockReturnValue([ + 'new search term', + mockSetSearchTermToStorage, + ]); mockUseAvailablePackages.mockReturnValue({ isLoading: false, diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx index a68c6d1babbf6..8de3f82a3e071 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -28,7 +28,7 @@ import { } from './constants'; import { INTEGRATION_TABS, INTEGRATION_TABS_BY_ID } from './integration_tabs_configs'; import { useIntegrationCardList } from './use_integration_card_list'; -import type { IntegrationTabId } from './types'; +import { IntegrationTabId } from './types'; interface WrapperProps { useAvailablePackages: AvailablePackagesHookType; @@ -42,11 +42,12 @@ const emptyStateStyles = { paddingTop: '16px' }; export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProps) => { const { spaceId } = useOnboardingContext(); const scrollElement = useRef(null); - const [selectedTabId, setSelectedTabIdToStorage] = useStoredIntegrationTabId( + const [selectedTabIdFromStorage, setSelectedTabIdToStorage] = useStoredIntegrationTabId( spaceId, DEFAULT_TAB.id ); - const [toggleIdSelected, setToggleIdSelected] = useState(selectedTabId); + const [toggleIdSelected, setToggleIdSelected] = + useState(selectedTabIdFromStorage); const [searchTermFromStorage, setSearchTermToStorage] = useStoredIntegrationSearchTerm(spaceId); const onTabChange = useCallback( (id: string) => { @@ -71,14 +72,18 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp prereleaseIntegrationsEnabled: false, }); - const selectedTab = useMemo(() => INTEGRATION_TABS_BY_ID[selectedTabId], [selectedTabId]); + const selectedTab = useMemo(() => INTEGRATION_TABS_BY_ID[toggleIdSelected], [toggleIdSelected]); const onSearchTermChanged = useCallback( (searchQuery: string) => { setSearchTerm(searchQuery); - setSearchTermToStorage(searchQuery); + // Search term is preserved across VISIBLE tabs + // As we want user to be able to see the same search results when coming back from Fleet + if (selectedTab.showSearchTools) { + setSearchTermToStorage(searchQuery); + } }, - [setSearchTerm, setSearchTermToStorage] + [selectedTab.showSearchTools, setSearchTerm, setSearchTermToStorage] ); useEffect(() => { @@ -89,7 +94,11 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp onSearchTermChanged(''); } - if (selectedTab.showSearchTools && searchTermFromStorage) { + if ( + selectedTab.showSearchTools && + searchTermFromStorage && + toggleIdSelected !== IntegrationTabId.recommended + ) { setSearchTerm(searchTermFromStorage); } }, [ @@ -101,6 +110,7 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp setCategory, setSearchTerm, setSelectedSubCategory, + toggleIdSelected, ]); const list: IntegrationCardItem[] = useIntegrationCardList({ @@ -162,7 +172,7 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp setUrlandReplaceHistory={noop} showCardLabels={false} showControls={false} - showSearchTools={selectedTab.showSearchTools ?? true} + showSearchTools={selectedTab.showSearchTools} spacer={false} /> From e833767a8bc8c6aa7aeb634aef9d9a1cac65b19f Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 7 Oct 2024 13:51:16 +0100 Subject: [PATCH 28/35] unit tests --- .../integration_card_top_callout.tsx | 11 +++ .../integration_card_top_callout.test.tsx | 75 +++++++++++++++++++ .../manage_integrations_callout.test.tsx | 48 ++++++++++++ .../callouts/manage_integrations_callout.tsx | 1 + .../integration_card_grid_tabs.test.tsx | 32 ++------ .../integrations/integrations_card.test.tsx | 44 +++++++++++ .../cards/integrations/integrations_card.tsx | 2 +- .../integrations/package_list_grid.test.tsx | 35 ++++++--- .../cards/integrations/package_list_grid.tsx | 11 ++- .../use_integration_card_list.test.ts | 14 ++-- .../cards/integrations/utils.ts | 20 ----- 11 files changed, 222 insertions(+), 71 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/integration_card_top_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/integration_card_top_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/integration_card_top_callout.tsx new file mode 100644 index 0000000000000..c51593181b33e --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/integration_card_top_callout.tsx @@ -0,0 +1,11 @@ +/* + * 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 from 'react'; + +export const IntegrationCardTopCallout = jest.fn(() => ( +
+)); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx new file mode 100644 index 0000000000000..8509038d8cb1a --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx @@ -0,0 +1,75 @@ +/* + * 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 from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { of } from 'rxjs'; +import { IntegrationCardTopCallout } from './integration_card_top_callout'; +import { useOnboardingService } from '../../../../../hooks/use_onboarding_service'; +import * as consts from '../constants'; + +jest.mock('../../../../../hooks/use_onboarding_service', () => ({ + useOnboardingService: jest.fn(), +})); + +jest.mock('./agentless_available_callout'); +jest.mock('./installed_integrations_callout'); + +interface MockedConsts { + AGENTLESS_LEARN_MORE_LINK: string | null; +} +jest.mock('../constants'); + +describe('IntegrationCardTopCallout', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(consts).AGENTLESS_LEARN_MORE_LINK = 'https://www.elastic.co'; + }); + + test('renders AgentlessAvailableCallout when agentless is available and no integrations installed', async () => { + (useOnboardingService as jest.Mock).mockReturnValue({ + isAgentlessAvailable$: of(true), + }); + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId('agentlessAvailableCallout')).toBeInTheDocument(); + }); + }); + + it('returns null if AGENTLESS_LEARN_MORE_LINK is null', async () => { + (useOnboardingService as jest.Mock).mockReturnValue({ + isAgentlessAvailable$: of(true), + }); + jest.mocked(consts).AGENTLESS_LEARN_MORE_LINK = null; + + const { queryByTestId } = render( + + ); + + await waitFor(() => { + expect(queryByTestId('agentlessAvailableCallout')).not.toBeInTheDocument(); + }); + }); + + test('renders InstalledIntegrationsCallout when there are installed integrations', async () => { + (useOnboardingService as jest.Mock).mockReturnValue({ + isAgentlessAvailable$: of(false), + }); + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId('installedIntegrationsCallout')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.test.tsx new file mode 100644 index 0000000000000..5f16bf3981f5f --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.test.tsx @@ -0,0 +1,48 @@ +/* + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { ManageIntegrationsCallout } from './manage_integrations_callout'; +import { TestProviders } from '../../../../../../common/mock/test_providers'; + +jest.mock('../../../../../../common/hooks/use_add_integrations_url', () => ({ + useAddIntegrationsUrl: jest.fn().mockReturnValue({ + href: '/test-url', + onClick: jest.fn(), + }), +})); + +jest.mock('../../common/card_callout', () => ({ + CardCallOut: ({ text }: { text: React.ReactNode }) =>
{text}
, +})); + +describe('ManageIntegrationsCallout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('renders nothing when installedIntegrationsCount is 0', () => { + const { queryByTestId } = render(, { + wrapper: TestProviders, + }); + + expect(queryByTestId('integrationsCompleteText')).not.toBeInTheDocument(); + }); + + test('renders callout with correct message and link when there are installed integrations', () => { + const { getByText, getByTestId } = render( + , + { + wrapper: TestProviders, + } + ); + + expect(getByText('5 integrations have been added')).toBeInTheDocument(); + expect(getByTestId('manageIntegrationsLink')).toHaveTextContent('Manage integrations'); + expect(getByTestId('manageIntegrationsLink')).toHaveAttribute('href', '/test-url'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx index f40d9b79f2b79..3a052d927ff10 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx @@ -25,6 +25,7 @@ export const ManageIntegrationsCallout = React.memo( color="primary" text={ ({ - fetchAvailablePackagesHook: jest.fn(), -})); +jest.mock('@kbn/fleet-plugin/public'); jest.mock('./package_list_grid'); -describe('IntegrationsCardGridTabs', () => { - const mockRetry = jest.fn(); +jest + .spyOn(module, 'AvailablePackagesHook') + .mockImplementation(() => Promise.resolve({ useAvailablePackages: jest.fn() })); +describe('IntegrationsCardGridTabs', () => { beforeEach(() => { jest.clearAllMocks(); }); it('shows loading skeleton while fetching data', () => { - (useAsyncRetry as jest.Mock).mockReturnValue({ - error: null, - retry: mockRetry, - loading: true, - }); - const { getByTestId } = render(, { wrapper: TestProviders }); expect(getByTestId('loadingPackages')).toBeInTheDocument(); }); it('renders PackageListGrid when data is loaded successfully', async () => { - const mockAvailablePackages = jest.fn(); - (fetchAvailablePackagesHook as jest.Mock).mockResolvedValue(mockAvailablePackages); - - (useAsyncRetry as jest.Mock).mockImplementation(async (cb) => { - await cb(); - return { - error: null, - retry: mockRetry, - loading: false, - }; - }); - const { getByTestId } = render(, { wrapper: TestProviders }); await waitFor(() => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx new file mode 100644 index 0000000000000..47caa26d98860 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 from 'react'; +import IntegrationsCard from './integrations_card'; +import { render } from '@testing-library/react'; +jest.mock('./integration_card_grid_tabs'); +jest.mock('./callouts/integration_card_top_callout'); + +const props = { + setComplete: jest.fn(), + isCardComplete: jest.fn(), + setExpandedCardId: jest.fn(), +}; + +describe('IntegrationsCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders a loading spinner when checkCompleteMetadata is undefined', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('loadingInstalledIntegrations')).toBeInTheDocument(); + }); + + it('renders the card when checkCompleteMetadata is defined', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('integrationCardTopCallout')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index 623c74f1eb971..eb21706a1d509 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -21,7 +21,7 @@ export const IntegrationsCard: OnboardingCardComponent = ({ checkCompleteMetadata, // this is undefined before the first checkComplete call finishes }) => { if (!isCheckCompleteMetadata(checkCompleteMetadata)) { - return ; + return ; } const { installedIntegrationsCount, isAgentRequired } = checkCompleteMetadata; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx index 9617483f1ac5b..ec35605f16a3a 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx @@ -5,20 +5,18 @@ * 2.0. */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, waitFor, act } from '@testing-library/react'; import { PackageListGrid } from './package_list_grid'; import { useStoredIntegrationSearchTerm, useStoredIntegrationTabId, } from '../../../../hooks/use_stored_state'; -import { PackageList } from './utils'; +import * as module from '@kbn/fleet-plugin/public'; import { DEFAULT_TAB } from './constants'; jest.mock('../../../onboarding_context'); jest.mock('../../../../hooks/use_stored_state'); -jest.mock('./utils', () => ({ - PackageList: jest.fn(() =>
), -})); + jest.mock('../../../../../common/lib/kibana', () => ({ useNavigation: jest.fn().mockReturnValue({ navigateTo: jest.fn(), @@ -26,9 +24,14 @@ jest.mock('../../../../../common/lib/kibana', () => ({ }), })); +const mockPackageList = jest.fn().mockReturnValue(
); + +jest + .spyOn(module, 'PackageList') + .mockImplementation(() => Promise.resolve({ PackageListGrid: mockPackageList })); + describe('PackageListGrid', () => { const mockUseAvailablePackages = jest.fn(); - const mockPackageList = PackageList as unknown as jest.Mock; const mockSetTabId = jest.fn(); const mockSetCategory = jest.fn(); const mockSetSelectedSubCategory = jest.fn(); @@ -56,7 +59,7 @@ describe('PackageListGrid', () => { expect(getByTestId('loadingPackages')).toBeInTheDocument(); }); - it('renders the package list when data is available', () => { + it('renders the package list when data is available', async () => { mockUseAvailablePackages.mockReturnValue({ isLoading: false, filteredCards: [{ id: 'card1', name: 'Card 1', url: 'https://mock-url' }], @@ -69,7 +72,9 @@ describe('PackageListGrid', () => { ); - expect(getByTestId('packageList')).toBeInTheDocument(); + await waitFor(() => { + expect(getByTestId('packageList')).toBeInTheDocument(); + }); }); it('saves the selected tab to storage', () => { @@ -89,11 +94,13 @@ describe('PackageListGrid', () => { const tabButton = getByTestId('user'); - fireEvent.click(tabButton); + act(() => { + fireEvent.click(tabButton); + }); expect(mockSetTabId).toHaveBeenCalledWith('user'); }); - it('renders no search tools when showSearchTools is false', () => { + it('renders no search tools when showSearchTools is false', async () => { mockUseAvailablePackages.mockReturnValue({ isLoading: false, filteredCards: [], @@ -104,7 +111,9 @@ describe('PackageListGrid', () => { render(); - expect(mockPackageList.mock.calls[0][0].showSearchTools).toEqual(false); + await waitFor(() => { + expect(mockPackageList.mock.calls[0][0].showSearchTools).toEqual(false); + }); }); it('updates the search term when the search input changes', async () => { @@ -125,6 +134,8 @@ describe('PackageListGrid', () => { render(); - expect(mockPackageList.mock.calls[0][0].searchTerm).toEqual('new search term'); + await waitFor(() => { + expect(mockPackageList.mock.calls[0][0].searchTerm).toEqual('new search term'); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx index 8de3f82a3e071..e2d24d68ded63 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -5,14 +5,13 @@ * 2.0. */ -import React, { Suspense, useMemo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { lazy, Suspense, useMemo, useCallback, useEffect, useRef, useState } from 'react'; import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiSkeletonText } from '@elastic/eui'; import type { AvailablePackagesHookType, IntegrationCardItem } from '@kbn/fleet-plugin/public'; import { noop } from 'lodash'; import { css } from '@emotion/react'; -import { PackageList } from './utils'; import { useStoredIntegrationSearchTerm, useStoredIntegrationTabId, @@ -39,6 +38,13 @@ const isIntegrationTabId = (id: string): id is IntegrationTabId => { }; const emptyStateStyles = { paddingTop: '16px' }; + +export const PackageList = lazy(async () => ({ + default: await import('@kbn/fleet-plugin/public') + .then((module) => module.PackageList()) + .then((pkg) => pkg.PackageListGrid), +})); + export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProps) => { const { spaceId } = useOnboardingContext(); const scrollElement = useRef(null); @@ -127,7 +133,6 @@ export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProp /> ); } - return ( ({ - useNavigation: jest.fn(), + useNavigation: jest.fn().mockReturnValue({ + navigateTo: jest.fn(), + getAppUrl: jest.fn(), + }), })); describe('useIntegrationCardList', () => { - const mockUseNavigation = useNavigation as jest.Mock; - const mockNavigateTo = jest.fn(); - const mockGetAppUrl = jest.fn(); const mockIntegrationsList = [ { id: 'security', @@ -37,10 +36,7 @@ describe('useIntegrationCardList', () => { ]; beforeEach(() => { - mockUseNavigation.mockReturnValue({ - navigateTo: mockNavigateTo, - getAppUrl: mockGetAppUrl, - }); + jest.clearAllMocks(); }); it('returns filtered integration cards when customCardNames are not provided', () => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts deleted file mode 100644 index 7abcb19c06b63..0000000000000 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 { lazy } from 'react'; -import type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public'; - -export const PackageList = lazy(async () => ({ - default: await import('@kbn/fleet-plugin/public') - .then((module) => module.PackageList()) - .then((pkg) => pkg.PackageListGrid), -})); - -export const fetchAvailablePackagesHook = (): Promise => - import('@kbn/fleet-plugin/public') - .then((module) => module.AvailablePackagesHook()) - .then((hook) => hook.useAvailablePackages); From 6712a29316615204d79e3cc5d52f22fc2747d5fd Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 7 Oct 2024 17:28:08 +0100 Subject: [PATCH 29/35] mv withLazyHook --- .../components/with_lazy_hook/index.tsx | 26 +++++++++++++++++++ .../integration_card_grid_tabs.tsx | 22 ++-------------- 2 files changed, 28 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/with_lazy_hook/index.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/with_lazy_hook/index.tsx b/x-pack/plugins/security_solution/public/common/components/with_lazy_hook/index.tsx new file mode 100644 index 0000000000000..f1fd4ae209cf5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/with_lazy_hook/index.tsx @@ -0,0 +1,26 @@ +/* + * 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, { useEffect, useState } from 'react'; +import type { PropsWithChildren } from 'react'; + +export const withLazyHook = ( + Component: React.ComponentType>, + moduleImport: () => Promise

, + fallback: React.ReactNode = null +) => { + return React.memo(function WithLazy(props: D) { + const [lazyModuleProp, setLazyModuleProp] = useState

(); + + useEffect(() => { + moduleImport().then((module) => { + setLazyModuleProp(() => module); + }); + }, []); + + return lazyModuleProp ? : <>{fallback}; + }); +}; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx index 841211763bfd6..234ae1075bc23 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx @@ -4,29 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useEffect, useState } from 'react'; -import type { PropsWithChildren } from 'react'; +import React from 'react'; import { EuiSkeletonText } from '@elastic/eui'; import { PackageListGrid } from './package_list_grid'; import { LOADING_SKELETON_HEIGHT } from './constants'; - -export const withLazyHook = ( - Component: React.ComponentType>, - moduleImport: () => Promise

, - fallback: React.ReactNode = null -) => { - return React.memo(function WithLazy(props: D) { - const [lazyModuleProp, setLazyModuleProp] = useState

(); - - useEffect(() => { - moduleImport().then((module) => { - setLazyModuleProp(() => module); - }); - }, []); - - return lazyModuleProp ? : <>{fallback}; - }); -}; +import { withLazyHook } from '../../../../../common/components/with_lazy_hook'; export const IntegrationsCardGridTabs = withLazyHook( PackageListGrid, From 1c65daca0739511229f00050349ebaa8d5c02efe Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 8 Oct 2024 13:21:04 +0100 Subject: [PATCH 30/35] add static integration list --- .../callouts/integration_card_top_callout.tsx | 2 - .../cards/integrations/constants.ts | 4 +- .../integration_card_grid_tabs.test.tsx | 13 +- .../integration_card_grid_tabs.tsx | 9 +- .../integrations/integration_tabs_configs.ts | 21 +- .../integrations/integrations_card.test.tsx | 14 - .../cards/integrations/integrations_card.tsx | 4 +- .../integrations/package_list_grid.test.tsx | 14 +- .../cards/integrations/package_list_grid.tsx | 281 +++++++++--------- .../cards/integrations/types.ts | 3 +- .../use_integration_card_list.test.ts | 14 +- .../integrations/use_integration_card_list.ts | 38 ++- 12 files changed, 226 insertions(+), 191 deletions(-) diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx index 0d0ca6fc61da1..798436a6523f6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { EuiSpacer } from '@elastic/eui'; import { useObservable } from 'react-use'; import { useOnboardingService } from '../../../../../hooks/use_onboarding_service'; @@ -37,7 +36,6 @@ export const IntegrationCardTopCallout = React.memo( installedIntegrationsCount={installedIntegrationsCount} /> )} - {(showAgentlessCallout || showInstalledCallout) && } ); } diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts index 84184df1ca685..fc4abc8f81e98 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts @@ -22,6 +22,6 @@ export const ONBOARDING_APP_ID = 'onboardingAppId'; export const ONBOARDING_LINK = 'onboardingLink'; export const SCROLL_ELEMENT_ID = 'integrations-scroll-container'; export const SEARCH_FILTER_CATEGORIES: CategoryFacet[] = []; -export const WITH_SEARCH_BOX_HEIGHT = '517px'; -export const WITHOUT_SEARCH_BOX_HEIGHT = '462px'; +export const WITH_SEARCH_BOX_HEIGHT = '568px'; +export const WITHOUT_SEARCH_BOX_HEIGHT = '513px'; export const DEFAULT_TAB: Tab = INTEGRATION_TABS[0]; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.test.tsx index 9581d035b0671..04ba6d1a49c62 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.test.tsx @@ -18,17 +18,26 @@ jest .mockImplementation(() => Promise.resolve({ useAvailablePackages: jest.fn() })); describe('IntegrationsCardGridTabs', () => { + const props = { + installedIntegrationsCount: 1, + isAgentRequired: false, + }; + beforeEach(() => { jest.clearAllMocks(); }); it('shows loading skeleton while fetching data', () => { - const { getByTestId } = render(, { wrapper: TestProviders }); + const { getByTestId } = render(, { + wrapper: TestProviders, + }); expect(getByTestId('loadingPackages')).toBeInTheDocument(); }); it('renders PackageListGrid when data is loaded successfully', async () => { - const { getByTestId } = render(, { wrapper: TestProviders }); + const { getByTestId } = render(, { + wrapper: TestProviders, + }); await waitFor(() => { expect(getByTestId('packageListGrid')).toBeInTheDocument(); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx index 234ae1075bc23..aff790f469810 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx @@ -6,11 +6,18 @@ */ import React from 'react'; import { EuiSkeletonText } from '@elastic/eui'; +import type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public'; import { PackageListGrid } from './package_list_grid'; import { LOADING_SKELETON_HEIGHT } from './constants'; import { withLazyHook } from '../../../../../common/components/with_lazy_hook'; -export const IntegrationsCardGridTabs = withLazyHook( +export const IntegrationsCardGridTabs = withLazyHook< + { + installedIntegrationsCount: number; + isAgentRequired: boolean; + }, + { useAvailablePackages: AvailablePackagesHookType } +>( PackageListGrid, () => import('@kbn/fleet-plugin/public').then((module) => module.AvailablePackagesHook()), { ); expect(getByTestId('loadingInstalledIntegrations')).toBeInTheDocument(); }); - - it('renders the card when checkCompleteMetadata is defined', () => { - const { getByTestId } = render( - - ); - expect(getByTestId('integrationCardTopCallout')).toBeInTheDocument(); - }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx index eb21706a1d509..61bfa80be8986 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.tsx @@ -11,7 +11,6 @@ import { OnboardingCardContentPanel } from '../common/card_content_panel'; import { IntegrationsCardGridTabs } from './integration_card_grid_tabs'; import { CenteredLoadingSpinner } from '../../../../../common/components/centered_loading_spinner'; import type { IntegrationCardMetadata } from './types'; -import { IntegrationCardTopCallout } from './callouts/integration_card_top_callout'; const isCheckCompleteMetadata = (metadata?: unknown): metadata is IntegrationCardMetadata => { return metadata !== undefined; @@ -27,11 +26,10 @@ export const IntegrationsCard: OnboardingCardComponent = ({ return ( - - ); }; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx index ec35605f16a3a..dff0c50e61e9a 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx @@ -36,6 +36,10 @@ describe('PackageListGrid', () => { const mockSetCategory = jest.fn(); const mockSetSelectedSubCategory = jest.fn(); const mockSetSearchTerm = jest.fn(); + const props = { + installedIntegrationsCount: 1, + isAgentRequired: false, + }; beforeEach(() => { jest.clearAllMocks(); @@ -53,7 +57,7 @@ describe('PackageListGrid', () => { }); const { getByTestId } = render( - + ); expect(getByTestId('loadingPackages')).toBeInTheDocument(); @@ -69,7 +73,7 @@ describe('PackageListGrid', () => { }); const { getByTestId } = render( - + ); await waitFor(() => { @@ -89,7 +93,7 @@ describe('PackageListGrid', () => { }); const { getByTestId } = render( - + ); const tabButton = getByTestId('user'); @@ -109,7 +113,7 @@ describe('PackageListGrid', () => { setSearchTerm: mockSetSearchTerm, }); - render(); + render(); await waitFor(() => { expect(mockPackageList.mock.calls[0][0].showSearchTools).toEqual(false); @@ -132,7 +136,7 @@ describe('PackageListGrid', () => { searchTerm: 'new search term', }); - render(); + render(); await waitFor(() => { expect(mockPackageList.mock.calls[0][0].searchTerm).toEqual('new search term'); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx index e2d24d68ded63..2605426dbf163 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -28,8 +28,11 @@ import { import { INTEGRATION_TABS, INTEGRATION_TABS_BY_ID } from './integration_tabs_configs'; import { useIntegrationCardList } from './use_integration_card_list'; import { IntegrationTabId } from './types'; +import { IntegrationCardTopCallout } from './callouts/integration_card_top_callout'; -interface WrapperProps { +export interface WrapperProps { + installedIntegrationsCount: number; + isAgentRequired: boolean; useAvailablePackages: AvailablePackagesHookType; } @@ -45,145 +48,157 @@ export const PackageList = lazy(async () => ({ .then((pkg) => pkg.PackageListGrid), })); -export const PackageListGrid = React.memo(({ useAvailablePackages }: WrapperProps) => { - const { spaceId } = useOnboardingContext(); - const scrollElement = useRef(null); - const [selectedTabIdFromStorage, setSelectedTabIdToStorage] = useStoredIntegrationTabId( - spaceId, - DEFAULT_TAB.id - ); - const [toggleIdSelected, setToggleIdSelected] = - useState(selectedTabIdFromStorage); - const [searchTermFromStorage, setSearchTermToStorage] = useStoredIntegrationSearchTerm(spaceId); - const onTabChange = useCallback( - (id: string) => { - if (!isIntegrationTabId(id)) { - return; - } - scrollElement.current?.scrollTo?.(0, 0); - setToggleIdSelected(id); - setSelectedTabIdToStorage(id); - }, - [setToggleIdSelected, setSelectedTabIdToStorage] - ); - - const { - filteredCards, - isLoading, - searchTerm, - setCategory, - setSearchTerm, - setSelectedSubCategory, - } = useAvailablePackages({ - prereleaseIntegrationsEnabled: false, - }); - - const selectedTab = useMemo(() => INTEGRATION_TABS_BY_ID[toggleIdSelected], [toggleIdSelected]); - - const onSearchTermChanged = useCallback( - (searchQuery: string) => { - setSearchTerm(searchQuery); - // Search term is preserved across VISIBLE tabs - // As we want user to be able to see the same search results when coming back from Fleet - if (selectedTab.showSearchTools) { - setSearchTermToStorage(searchQuery); +export const PackageListGrid = React.memo( + ({ installedIntegrationsCount, isAgentRequired, useAvailablePackages }: WrapperProps) => { + const { spaceId } = useOnboardingContext(); + const scrollElement = useRef(null); + const [selectedTabIdFromStorage, setSelectedTabIdToStorage] = useStoredIntegrationTabId( + spaceId, + DEFAULT_TAB.id + ); + const [toggleIdSelected, setToggleIdSelected] = + useState(selectedTabIdFromStorage); + const [searchTermFromStorage, setSearchTermToStorage] = useStoredIntegrationSearchTerm(spaceId); + const onTabChange = useCallback( + (id: string) => { + if (!isIntegrationTabId(id)) { + return; + } + scrollElement.current?.scrollTo?.(0, 0); + setToggleIdSelected(id); + setSelectedTabIdToStorage(id); + }, + [setToggleIdSelected, setSelectedTabIdToStorage] + ); + + const { + filteredCards, + isLoading, + searchTerm, + setCategory, + setSearchTerm, + setSelectedSubCategory, + } = useAvailablePackages({ + prereleaseIntegrationsEnabled: false, + }); + + const selectedTab = useMemo(() => INTEGRATION_TABS_BY_ID[toggleIdSelected], [toggleIdSelected]); + + const onSearchTermChanged = useCallback( + (searchQuery: string) => { + setSearchTerm(searchQuery); + // Search term is preserved across VISIBLE tabs + // As we want user to be able to see the same search results when coming back from Fleet + if (selectedTab.showSearchTools) { + setSearchTermToStorage(searchQuery); + } + }, + [selectedTab.showSearchTools, setSearchTerm, setSearchTermToStorage] + ); + + useEffect(() => { + setCategory(selectedTab.category ?? ''); + setSelectedSubCategory(selectedTab.subCategory); + if (!selectedTab.showSearchTools) { + // If search box are not shown, clear the search term to avoid unexpected filtering + onSearchTermChanged(''); } - }, - [selectedTab.showSearchTools, setSearchTerm, setSearchTermToStorage] - ); - - useEffect(() => { - setCategory(selectedTab.category ?? ''); - setSelectedSubCategory(selectedTab.subCategory); - if (!selectedTab.showSearchTools) { - // If search box are not shown, clear the search term to avoid unexpected filtering - onSearchTermChanged(''); - } - if ( - selectedTab.showSearchTools && - searchTermFromStorage && - toggleIdSelected !== IntegrationTabId.recommended - ) { - setSearchTerm(searchTermFromStorage); + if ( + selectedTab.showSearchTools && + searchTermFromStorage && + toggleIdSelected !== IntegrationTabId.recommended + ) { + setSearchTerm(searchTermFromStorage); + } + }, [ + onSearchTermChanged, + searchTermFromStorage, + selectedTab.category, + selectedTab.showSearchTools, + selectedTab.subCategory, + setCategory, + setSearchTerm, + setSelectedSubCategory, + toggleIdSelected, + ]); + + const list: IntegrationCardItem[] = useIntegrationCardList({ + integrationsList: filteredCards, + featuredCardIds: selectedTab.featuredCardIds, + }); + + if (isLoading) { + return ( + + ); } - }, [ - onSearchTermChanged, - searchTermFromStorage, - selectedTab.category, - selectedTab.showSearchTools, - selectedTab.subCategory, - setCategory, - setSearchTerm, - setSelectedSubCategory, - toggleIdSelected, - ]); - - const list: IntegrationCardItem[] = useIntegrationCardList({ - integrationsList: filteredCards, - customCardNames: selectedTab.customCardNames, - }); - - if (isLoading) { return ( - - ); - } - return ( - - - - - - }> - + - - - - ); -}); + + + }> + + } + calloutTopSpacerSize="m" + categories={SEARCH_FILTER_CATEGORIES} // We do not want to show categories and subcategories as the search bar filter + emptyStateStyles={emptyStateStyles} + list={list} + scrollElementId={SCROLL_ELEMENT_ID} + searchTerm={searchTerm} + selectedCategory={selectedTab.category ?? ''} + selectedSubCategory={selectedTab.subCategory} + setCategory={setCategory} + setSearchTerm={onSearchTermChanged} + setUrlandPushHistory={noop} + setUrlandReplaceHistory={noop} + showCardLabels={false} + showControls={false} + showSearchTools={selectedTab.showSearchTools} + sortByFeaturedIntegrations={selectedTab.sortByFeaturedIntegrations} + spacer={false} + /> + + + + ); + } +); PackageListGrid.displayName = 'PackageListGrid'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts index f18818c039115..849e9cdd2336b 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts @@ -6,13 +6,14 @@ */ export interface Tab { category: string; - customCardNames?: string[]; // custom card name e.g.: 1password + featuredCardIds?: string[]; iconType?: string; id: IntegrationTabId; label: string; overflow?: 'hidden' | 'scroll'; showSearchTools?: boolean; subCategory?: string; + sortByFeaturedIntegrations: boolean; } export enum IntegrationTabId { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts index f1b4ca4eb2bad..7a3ff6c0ca3a8 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.ts @@ -17,7 +17,7 @@ jest.mock('../../../../../common/lib/kibana', () => ({ describe('useIntegrationCardList', () => { const mockIntegrationsList = [ { - id: 'security', + id: 'epr:endpoint', name: 'Security Integration', description: 'Integration for security monitoring', categories: ['security'], @@ -39,7 +39,7 @@ describe('useIntegrationCardList', () => { jest.clearAllMocks(); }); - it('returns filtered integration cards when customCardNames are not provided', () => { + it('returns filtered integration cards when featuredCardIds are not provided', () => { const mockFilteredCards = { featuredCards: {}, integrationCards: mockIntegrationsList, @@ -54,11 +54,11 @@ describe('useIntegrationCardList', () => { expect(result.current).toEqual(mockFilteredCards.integrationCards); }); - it('returns featured cards when customCardNames are provided', () => { - const customCardNames = ['Security Integration']; + it('returns featured cards when featuredCardIds are provided', () => { + const featuredCardIds = ['epr:endpoint']; const mockFilteredCards = { featuredCards: { - 'Security Integration': mockIntegrationsList[0], + 'epr:endpoint': mockIntegrationsList[0], }, integrationCards: mockIntegrationsList, }; @@ -66,10 +66,10 @@ describe('useIntegrationCardList', () => { const { result } = renderHook(() => useIntegrationCardList({ integrationsList: mockIntegrationsList, - customCardNames, + featuredCardIds, }) ); - expect(result.current).toEqual([mockFilteredCards.featuredCards['Security Integration']]); + expect(result.current).toEqual([mockFilteredCards.featuredCards['epr:endpoint']]); }); }); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts index 371f535bc3c7c..bb5da356306af 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts @@ -33,26 +33,23 @@ const addPathParamToUrl = (url: string, onboardingLink: string) => { return `${url}?${paramsString}`; }; -const extractFeaturedCards = ( - filteredCards: IntegrationCardItem[], - featuredCardNames?: string[] -) => { - return filteredCards.reduce((acc: Record, card) => { - if (featuredCardNames?.includes(card.name)) { - acc[card.name] = card; +const extractFeaturedCards = (filteredCards: IntegrationCardItem[], featuredCardIds: string[]) => { + return filteredCards.reduce((acc, card) => { + if (featuredCardIds.includes(card.id)) { + acc.push(card); } return acc; - }, {}); + }, []); }; const getFilteredCards = ({ - customCardNames, + featuredCardIds, getAppUrl, installedIntegrationList, integrationsList, navigateTo, }: { - customCardNames?: string[]; + featuredCardIds?: string[]; getAppUrl: GetAppUrl; installedIntegrationList?: IntegrationCardItem[]; integrationsList: IntegrationCardItem[]; @@ -61,12 +58,12 @@ const getFilteredCards = ({ const securityIntegrationsList = integrationsList.map((card) => addSecuritySpecificProps({ navigateTo, getAppUrl, card, installedIntegrationList }) ); - if (!customCardNames) { - return { featuredCards: {}, integrationCards: securityIntegrationsList }; + if (!featuredCardIds) { + return { featuredCards: [], integrationCards: securityIntegrationsList }; } - + const featuredCards = extractFeaturedCards(securityIntegrationsList, featuredCardIds); return { - featuredCards: extractFeaturedCards(securityIntegrationsList, customCardNames), + featuredCards, integrationCards: securityIntegrationsList, }; }; @@ -117,19 +114,20 @@ const addSecuritySpecificProps = ({ export const useIntegrationCardList = ({ integrationsList, - customCardNames, + featuredCardIds, }: { integrationsList: IntegrationCardItem[]; - customCardNames?: string[] | undefined; + featuredCardIds?: string[] | undefined; }): IntegrationCardItem[] => { const { navigateTo, getAppUrl } = useNavigation(); + const { featuredCards, integrationCards } = useMemo( - () => getFilteredCards({ navigateTo, getAppUrl, integrationsList, customCardNames }), - [navigateTo, getAppUrl, integrationsList, customCardNames] + () => getFilteredCards({ navigateTo, getAppUrl, integrationsList, featuredCardIds }), + [navigateTo, getAppUrl, integrationsList, featuredCardIds] ); - if (customCardNames && customCardNames.length > 0) { - return Object.values(featuredCards) ?? []; + if (featuredCardIds && featuredCardIds.length > 0) { + return featuredCards; } return integrationCards ?? []; }; From 90e72aede76f8c56a6771a865feb31132fa7f844 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 8 Oct 2024 13:21:44 +0100 Subject: [PATCH 31/35] support custom sorting on integrations --- .../epm/components/package_list_grid/grid.tsx | 1 - .../epm/components/package_list_grid/index.tsx | 15 +++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx index 4338cfb2bc918..80cb14dcfb16c 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/grid.tsx @@ -68,7 +68,6 @@ export const GridColumn = ({ }: GridColumnProps) => { const itemsSizeRefs = useRef(new Map()); const listRef = useRef(null); - const onHeightChange = useCallback((index: number, size: number) => { itemsSizeRefs.current.set(index, size); if (listRef.current) { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx index be2b873c317db..ba90ed6509f95 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx @@ -61,6 +61,8 @@ export interface PackageListGridProps { setUrlandReplaceHistory: (params: IntegrationsURLParameters) => void; setUrlandPushHistory: (params: IntegrationsURLParameters) => void; callout?: JSX.Element | null; + // Props to decide the size of the spacer above callout. Security Solution uses this prop to customize the size of the spacer + calloutTopSpacerSize?: 's' | 'm' | 'xs' | 'l' | 'xl' | 'xxl'; // Props used only in AvailablePackages component: showCardLabels?: boolean; title?: string; @@ -70,6 +72,8 @@ export interface PackageListGridProps { showMissingIntegrationMessage?: boolean; showControls?: boolean; showSearchTools?: boolean; + // Customizing whether to sort by the default featured integrations' categories. Security Solution has custom sorting logic + sortByFeaturedIntegrations?: boolean; spacer?: boolean; // Security Solution sends the id to determine which element to scroll when the user interacting with the package list scrollElementId?: string; @@ -92,7 +96,9 @@ export const PackageListGrid: FunctionComponent = ({ setUrlandReplaceHistory, setUrlandPushHistory, showMissingIntegrationMessage = false, + sortByFeaturedIntegrations = true, callout, + calloutTopSpacerSize = 'l', // Default EUI spacer size showCardLabels = true, showControls = true, showSearchTools = true, @@ -141,9 +147,10 @@ export const PackageListGrid: FunctionComponent = ({ ) : list; - return promoteFeaturedIntegrations(filteredList, selectedCategory); - }, [isLoading, list, localSearchRef, searchTerm, selectedCategory]); - + return sortByFeaturedIntegrations + ? promoteFeaturedIntegrations(filteredList, selectedCategory) + : filteredList; + }, [isLoading, list, localSearchRef, searchTerm, selectedCategory, sortByFeaturedIntegrations]); const splitSubcategories = ( subcategories: CategoryFacet[] | undefined ): { visibleSubCategories?: CategoryFacet[]; hiddenSubCategories?: CategoryFacet[] } => { @@ -270,7 +277,7 @@ export const PackageListGrid: FunctionComponent = ({ ) : null} {callout ? ( <> - + {callout} ) : null} From 506c929b3adf9a9f1757109b4b798b0939716872 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 8 Oct 2024 15:16:28 +0100 Subject: [PATCH 32/35] add endpoint callout --- .../callouts/__mocks__/endpoint_callout.tsx | 9 +++ .../callouts/endpoint_callout.tsx | 67 +++++++++++++++++++ .../integration_card_top_callout.test.tsx | 40 +++++++++-- .../callouts/integration_card_top_callout.tsx | 13 +++- .../cards/integrations/constants.ts | 4 +- .../cards/integrations/package_list_grid.tsx | 1 + 6 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/endpoint_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.tsx diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/endpoint_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/endpoint_callout.tsx new file mode 100644 index 0000000000000..c2bdfdc72ea10 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/endpoint_callout.tsx @@ -0,0 +1,9 @@ +/* + * 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 from 'react'; + +export const EndpointCallout = () =>

; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.tsx new file mode 100644 index 0000000000000..408c8d227b96c --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/endpoint_callout.tsx @@ -0,0 +1,67 @@ +/* + * 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 from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiIcon, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +import { LinkAnchor } from '../../../../../../common/components/links'; +import { CardCallOut } from '../../common/card_callout'; +import { ENDPOINT_LEARN_MORE_LINK } from '../constants'; + +export const EndpointCallout = React.memo(() => { + const { euiTheme } = useEuiTheme(); + + return ( + , + new: ( + + + + ), + text: ( + + ), + link: ( + + + + ), + }} + /> + } + /> + ); +}); + +EndpointCallout.displayName = 'EndpointCallout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx index 8509038d8cb1a..9dcce0603e97d 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.test.tsx @@ -11,6 +11,7 @@ import { of } from 'rxjs'; import { IntegrationCardTopCallout } from './integration_card_top_callout'; import { useOnboardingService } from '../../../../../hooks/use_onboarding_service'; import * as consts from '../constants'; +import { IntegrationTabId } from '../types'; jest.mock('../../../../../hooks/use_onboarding_service', () => ({ useOnboardingService: jest.fn(), @@ -18,6 +19,7 @@ jest.mock('../../../../../hooks/use_onboarding_service', () => ({ jest.mock('./agentless_available_callout'); jest.mock('./installed_integrations_callout'); +jest.mock('./endpoint_callout'); interface MockedConsts { AGENTLESS_LEARN_MORE_LINK: string | null; @@ -30,13 +32,35 @@ describe('IntegrationCardTopCallout', () => { jest.mocked(consts).AGENTLESS_LEARN_MORE_LINK = 'https://www.elastic.co'; }); + test('renders EndpointCallout when endpoint tab selected and no integrations installed', async () => { + (useOnboardingService as jest.Mock).mockReturnValue({ + isAgentlessAvailable$: of(true), + }); + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId('endpointCallout')).toBeInTheDocument(); + }); + }); + test('renders AgentlessAvailableCallout when agentless is available and no integrations installed', async () => { (useOnboardingService as jest.Mock).mockReturnValue({ isAgentlessAvailable$: of(true), }); const { getByTestId } = render( - + ); await waitFor(() => { @@ -44,14 +68,18 @@ describe('IntegrationCardTopCallout', () => { }); }); - it('returns null if AGENTLESS_LEARN_MORE_LINK is null', async () => { + it('does not render AgentlessAvailableCallout if AGENTLESS_LEARN_MORE_LINK is null', async () => { (useOnboardingService as jest.Mock).mockReturnValue({ isAgentlessAvailable$: of(true), }); jest.mocked(consts).AGENTLESS_LEARN_MORE_LINK = null; const { queryByTestId } = render( - + ); await waitFor(() => { @@ -65,7 +93,11 @@ describe('IntegrationCardTopCallout', () => { }); const { getByTestId } = render( - + ); await waitFor(() => { diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx index 798436a6523f6..5ca2cc07f8db4 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx @@ -12,23 +12,33 @@ import { useOnboardingService } from '../../../../../hooks/use_onboarding_servic import { AGENTLESS_LEARN_MORE_LINK } from '../constants'; import { AgentlessAvailableCallout } from './agentless_available_callout'; import { InstalledIntegrationsCallout } from './installed_integrations_callout'; +import { IntegrationTabId } from '../types'; +import { EndpointCallout } from './endpoint_callout'; export const IntegrationCardTopCallout = React.memo( ({ installedIntegrationsCount, isAgentRequired, + selectedTabId, }: { installedIntegrationsCount: number; isAgentRequired: boolean; + selectedTabId: IntegrationTabId; }) => { const { isAgentlessAvailable$ } = useOnboardingService(); const isAgentlessAvailable = useObservable(isAgentlessAvailable$, undefined); const showAgentlessCallout = - isAgentlessAvailable && AGENTLESS_LEARN_MORE_LINK && installedIntegrationsCount === 0; + isAgentlessAvailable && + AGENTLESS_LEARN_MORE_LINK && + installedIntegrationsCount === 0 && + selectedTabId !== IntegrationTabId.endpoint; + const showEndpointCallout = + installedIntegrationsCount === 0 && selectedTabId === IntegrationTabId.endpoint; const showInstalledCallout = installedIntegrationsCount > 0 || isAgentRequired; return ( <> + {showEndpointCallout && } {showAgentlessCallout && } {showInstalledCallout && ( )} + {} ); } diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts index fc4abc8f81e98..db5f01f2b2314 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts @@ -14,6 +14,9 @@ export const AGENT_INDEX = `logs-elastic_agent*`; export const AGENTLESS_LEARN_MORE_LINK = null; // Link to be confirmed. export const CARD_DESCRIPTION_LINE_CLAMP = 3; // 3 lines of text export const CARD_TITLE_LINE_CLAMP = 1; // 1 line of text +export const DEFAULT_TAB: Tab = INTEGRATION_TABS[0]; +export const ENDPOINT_LEARN_MORE_LINK = + 'https://www.elastic.co/guide/en/security/current/third-party-actions.html'; export const FLEET_APP_ID = `fleet`; export const INTEGRATION_APP_ID = `integrations`; export const LOADING_SKELETON_HEIGHT = 10; // 10 lines of text @@ -24,4 +27,3 @@ export const SCROLL_ELEMENT_ID = 'integrations-scroll-container'; export const SEARCH_FILTER_CATEGORIES: CategoryFacet[] = []; export const WITH_SEARCH_BOX_HEIGHT = '568px'; export const WITHOUT_SEARCH_BOX_HEIGHT = '513px'; -export const DEFAULT_TAB: Tab = INTEGRATION_TABS[0]; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx index 2605426dbf163..07b2a5779f21c 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -174,6 +174,7 @@ export const PackageListGrid = React.memo( } calloutTopSpacerSize="m" From c6d35d250e28ec5b9f92d9b28aafb1838a390a0d Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 8 Oct 2024 15:22:34 +0100 Subject: [PATCH 33/35] remove duplicated state --- .../cards/integrations/package_list_grid.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx index 07b2a5779f21c..f8c2f3b48e1af 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { lazy, Suspense, useMemo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { lazy, Suspense, useMemo, useCallback, useEffect, useRef } from 'react'; import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiSkeletonText } from '@elastic/eui'; import type { AvailablePackagesHookType, IntegrationCardItem } from '@kbn/fleet-plugin/public'; @@ -52,12 +52,10 @@ export const PackageListGrid = React.memo( ({ installedIntegrationsCount, isAgentRequired, useAvailablePackages }: WrapperProps) => { const { spaceId } = useOnboardingContext(); const scrollElement = useRef(null); - const [selectedTabIdFromStorage, setSelectedTabIdToStorage] = useStoredIntegrationTabId( + const [toggleIdSelected, setSelectedTabIdToStorage] = useStoredIntegrationTabId( spaceId, DEFAULT_TAB.id ); - const [toggleIdSelected, setToggleIdSelected] = - useState(selectedTabIdFromStorage); const [searchTermFromStorage, setSearchTermToStorage] = useStoredIntegrationSearchTerm(spaceId); const onTabChange = useCallback( (id: string) => { @@ -65,10 +63,9 @@ export const PackageListGrid = React.memo( return; } scrollElement.current?.scrollTo?.(0, 0); - setToggleIdSelected(id); setSelectedTabIdToStorage(id); }, - [setToggleIdSelected, setSelectedTabIdToStorage] + [setSelectedTabIdToStorage] ); const { From 71edb9cab8fc472a6c48aaa84e61f818639234b8 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 8 Oct 2024 15:25:37 +0100 Subject: [PATCH 34/35] type casting --- .../cards/integrations/package_list_grid.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx index f8c2f3b48e1af..0747c9be25f4f 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -58,10 +58,8 @@ export const PackageListGrid = React.memo( ); const [searchTermFromStorage, setSearchTermToStorage] = useStoredIntegrationSearchTerm(spaceId); const onTabChange = useCallback( - (id: string) => { - if (!isIntegrationTabId(id)) { - return; - } + (stringId: string) => { + const id = stringId as IntegrationTabId; scrollElement.current?.scrollTo?.(0, 0); setSelectedTabIdToStorage(id); }, From cfd190c9715ed8e01c18ef9b9edc8603fbd0e8c3 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 9 Oct 2024 17:25:48 +0100 Subject: [PATCH 35/35] update network sub category --- .../cards/integrations/integration_tabs_configs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts index 89cf83f8c1f9d..2e673d98278a3 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts @@ -33,7 +33,7 @@ export const INTEGRATION_TABS: Tab[] = [ category: 'security', id: IntegrationTabId.network, label: 'Network', - subCategory: 'network_security', + subCategory: 'network', showSearchTools: true, sortByFeaturedIntegrations: true, },