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..a37730ad1570c --- /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('Installed')).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 new file mode 100644 index 0000000000000..d053fd76fb391 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/installation_status.tsx @@ -0,0 +1,87 @@ +/* + * 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 { 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: installedLabel, + }, + install_failed: { + color: 'warning', + iconType: 'warning', + title: installedLabel, + }, +}; + +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.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.test.tsx index 961fc2916cde8..716f59231a4c4 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.test.tsx @@ -14,6 +14,7 @@ import { useStartServices } from '../../../hooks'; import type { PackageCardProps } from './package_card'; import { PackageCard } from './package_card'; +import { getLineClampStyles, shouldShowInstallationStatus } from './installation_status'; jest.mock('../../../hooks', () => { 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 474ffe2e4db70..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 @@ -35,13 +35,21 @@ import { InlineReleaseBadge, WithGuidedOnboardingTour } from '../../../component import { useStartServices, useIsGuidedOnboardingActive } from '../../../hooks'; import { INTEGRATIONS_BASE_PATH, INTEGRATIONS_PLUGIN_ID } from '../../../constants'; +import { + InstallationStatus, + getLineClampStyles, + shouldShowInstallationStatus, +} from './installation_status'; + 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; $maxCardHeight?: number }>` min-height: 127px; border-color: ${({ isquickstart }) => (isquickstart ? '#ba3d76' : null)}; + ${({ $maxCardHeight }) => + $maxCardHeight ? `max-height: ${$maxCardHeight}px; overflow: hidden;` : ''}; `; export function PackageCard({ @@ -59,10 +67,15 @@ export function PackageCard({ isUnverified, isUpdateAvailable, showLabels = true, + showInstallationStatus, extraLabelsBadges, isQuickstart = false, + installStatus, onCardClick: onClickProp = undefined, isCollectionCard = false, + titleLineClamp, + descriptionLineClamp, + maxCardHeight, }: PackageCardProps) { let releaseBadge: React.ReactNode | null = null; @@ -178,6 +191,7 @@ export function PackageCard({ } onClick={onClickProp ?? onCardClick} + $maxCardHeight={maxCardHeight} > {showLabels && extraLabelsBadges ? extraLabelsBadges : null} @@ -214,6 +238,10 @@ export function PackageCard({ {releaseBadge} {hasDeferredInstallationsBadge} {collectionButton} + 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..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 @@ -28,6 +28,8 @@ interface GridColumnProps { isLoading: boolean; showMissingIntegrationMessage?: boolean; showCardLabels?: boolean; + scrollElementId?: string; + emptyStateStyles?: Record; } const VirtualizedRow: React.FC<{ @@ -61,10 +63,11 @@ export const GridColumn = ({ showMissingIntegrationMessage = false, showCardLabels = false, isLoading, + scrollElementId, + emptyStateStyles, }: 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) { @@ -86,7 +89,7 @@ export const GridColumn = ({ if (!list.length) { return ( - +

@@ -107,6 +110,7 @@ export const GridColumn = ({ ); } + return ( <> {() => ( 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/components/package_list_grid/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid/index.tsx index 8a6761d48f9b1..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 @@ -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; @@ -60,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; @@ -69,11 +72,17 @@ 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; } export const PackageListGrid: FunctionComponent = ({ isLoading, controls, + emptyStateStyles, title, list, searchTerm, @@ -87,10 +96,14 @@ export const PackageListGrid: FunctionComponent = ({ setUrlandReplaceHistory, setUrlandPushHistory, showMissingIntegrationMessage = false, + sortByFeaturedIntegrations = true, callout, + calloutTopSpacerSize = 'l', // Default EUI spacer size showCardLabels = true, showControls = true, showSearchTools = true, + spacer = true, + scrollElementId, }) => { const localSearchRef = useLocalSearch(list, !!isLoading); @@ -134,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[] } => { @@ -263,17 +277,19 @@ export const PackageListGrid: FunctionComponent = ({ ) : null} {callout ? ( <> - + {callout} ) : null} - + {spacer && } {showMissingIntegrationMessage && ( 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/components/back_link.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx index 081b78de8ec51..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 @@ -17,7 +17,9 @@ interface Props { export function BackLink({ queryParams, href: integrationsHref }: Props) { const { onboardingLink } = useMemo(() => { return { - onboardingLink: queryParams.get('observabilityOnboardingLink'), + onboardingLink: + // Users from Security Solution onboarding page will have onboardingLink to redirect back to the onboarding page + queryParams.get('observabilityOnboardingLink') || queryParams.get('onboardingLink'), }; }, [queryParams]); const href = onboardingLink ?? integrationsHref; 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..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 @@ -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'; @@ -135,6 +136,11 @@ 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]); const authz = useAuthz(); const canAddAgent = authz.fleet.addAgents; @@ -388,7 +394,7 @@ export function Detail() { hash, }); - const navigateOptions = getInstallPkgRouteOptions({ + const defaultNavigateOptions: InstallPkgRouteOptions = getInstallPkgRouteOptions({ agentPolicyId: agentPolicyIdFromContext, currentPath, integration, @@ -399,6 +405,25 @@ 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 + ? [ + defaultNavigateOptions[0], + { + ...defaultNavigateOptions[1], + state: { + ...(defaultNavigateOptions[1]?.state ?? {}), + onCancelNavigateTo: [onboardingAppId, { path: onboardingLink }], + onCancelUrl: onboardingLink, + onSaveNavigateTo: [onboardingAppId, { path: onboardingLink }], + }, + }, + ] + : defaultNavigateOptions; + services.application.navigateToApp(...navigateOptions); }, [ @@ -410,6 +435,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/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..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 @@ -85,6 +85,51 @@ describe('Card utils', () => { isUpdateAvailable: false, }); }); + + 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', + }); + }); + + it('should not return 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, + }); + }); }); describe('getIntegrationLabels', () => { it('should return an empty list for an integration without errors', () => { 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..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 @@ -27,6 +27,7 @@ import { getPackageReleaseLabel } from '../../../../../../../common/services'; import { installationStatuses } from '../../../../../../../common/constants'; import type { + EpmPackageInstallStatus, InstallFailedAttempt, IntegrationCardReleaseLabel, PackageSpecIcon, @@ -38,25 +39,32 @@ 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; + // Security Solution uses this prop to determine how many lines the card description should be truncated + descriptionLineClamp?: number; + extraLabelsBadges?: React.ReactNode[]; + maxCardHeight?: number; + fromIntegrations?: string; icons: Array; - integration: string; id: string; - categories: string[]; - fromIntegrations?: string; + installStatus?: EpmPackageInstallStatus; + 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; + 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; } export const mapToCard = ({ @@ -110,7 +118,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, @@ -127,6 +135,12 @@ export const mapToCard = ({ isUpdateAvailable, extraLabelsBadges, }; + + if (item.type === 'integration') { + cardResult.installStatus = item.installationInfo?.install_status; + } + + return cardResult; }; export function getIntegrationLabels(item: PackageListItem): React.ReactNode[] { diff --git a/x-pack/plugins/fleet/public/index.ts b/x-pack/plugins/fleet/public/index.ts index 7661cbc64ad31..d82e9c88b7db8 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_API_ROUTES } from './constants'; export { pkgKeyFromPackageInfo } from './services'; export type { CustomAssetsAccordionProps } from './components/custom_assets_accordion'; export { CustomAssetsAccordion } from './components/custom_assets_accordion'; @@ -77,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/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/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/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/common/card_content_panel.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/common/card_content_panel.tsx index c4039955e4216..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/integrations_header_badges.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/integration_card_grid_tabs.tsx similarity index 64% rename from x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_header_badges.tsx rename to x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/integration_card_grid_tabs.tsx index 3eb9372935f7c..660d7b881e397 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/__mocks__/integration_card_grid_tabs.tsx @@ -6,8 +6,5 @@ */ import React from 'react'; -import { EuiBadge } from '@elastic/eui'; -export const getDummyAdditionalBadge = () => { - return {'Dummy badge'}; -}; +export const IntegrationsCardGridTabs = () =>

; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/package_list_grid.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/package_list_grid.tsx new file mode 100644 index 0000000000000..759dbf78bfb88 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/__mocks__/package_list_grid.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 PackageListGrid = () =>
; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__mocks__/agent_required_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__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/callouts/__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/callouts/__mocks__/agentless_available_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/__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/callouts/__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/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/__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__/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/__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/callouts/agent_required_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.test.tsx new file mode 100644 index 0000000000000..dbd0c105d27a1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/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/callouts/agent_required_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.tsx new file mode 100644 index 0000000000000..aad22c959bc65 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agent_required_callout.tsx @@ -0,0 +1,57 @@ +/* + * 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 '../constants'; + +const fleetAgentLinkProps = { appId: FLEET_APP_ID, path: ADD_AGENT_PATH }; + +export const AgentRequiredCallout = React.memo(() => { + const { getAppUrl, navigateTo } = useNavigation(); + const addAgentLink = getAppUrl(fleetAgentLinkProps); + const onAddAgentClick = useCallback(() => { + navigateTo(fleetAgentLinkProps); + }, [navigateTo]); + + return ( + + ), + link: ( + + + + ), + icon: , + }} + /> + } + /> + ); +}); + +AgentRequiredCallout.displayName = 'AgentRequiredCallout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.test.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.test.tsx new file mode 100644 index 0000000000000..03e5fe2bf748b --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.test.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 { 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 '../constants'; + +interface MockedConsts { + AGENTLESS_LEARN_MORE_LINK: string | null; +} +jest.mock('../constants'); + +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(, { + 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/callouts/agentless_available_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.tsx new file mode 100644 index 0000000000000..c222e70762652 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/agentless_available_callout.tsx @@ -0,0 +1,71 @@ +/* + * 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 { AGENTLESS_LEARN_MORE_LINK } from '../constants'; + +export const AgentlessAvailableCallout = React.memo(() => { + const { euiTheme } = useEuiTheme(); + + if (!AGENTLESS_LEARN_MORE_LINK) { + return null; + } + + return ( + , + new: ( + + + + ), + text: ( + + ), + link: ( + + + + ), + }} + /> + } + /> + ); +}); + +AgentlessAvailableCallout.displayName = 'AgentlessAvailableCallout'; 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/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.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..9dcce0603e97d --- /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,107 @@ +/* + * 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'; +import { IntegrationTabId } from '../types'; + +jest.mock('../../../../../hooks/use_onboarding_service', () => ({ + useOnboardingService: jest.fn(), +})); + +jest.mock('./agentless_available_callout'); +jest.mock('./installed_integrations_callout'); +jest.mock('./endpoint_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 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(() => { + expect(getByTestId('agentlessAvailableCallout')).toBeInTheDocument(); + }); + }); + + 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(() => { + 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/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..5ca2cc07f8db4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_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 from 'react'; +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'; +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 && + selectedTabId !== IntegrationTabId.endpoint; + const showEndpointCallout = + installedIntegrationsCount === 0 && selectedTabId === IntegrationTabId.endpoint; + const showInstalledCallout = installedIntegrationsCount > 0 || isAgentRequired; + + return ( + <> + {showEndpointCallout && } + {showAgentlessCallout && } + {showInstalledCallout && ( + + )} + {} + + ); + } +); + +IntegrationCardTopCallout.displayName = 'IntegrationCardTopCallout'; 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 new file mode 100644 index 0000000000000..3a052d927ff10 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/manage_integrations_callout.tsx @@ -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 React 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 { useAddIntegrationsUrl } from '../../../../../../common/hooks/use_add_integrations_url'; + +export const ManageIntegrationsCallout = React.memo( + ({ installedIntegrationsCount }: { installedIntegrationsCount: number }) => { + const { href: integrationUrl, onClick: onAddIntegrationClicked } = useAddIntegrationsUrl(); + + if (!installedIntegrationsCount) { + return null; + } + + return ( + + ), + link: ( + + + + ), + icon: , + }} + /> + } + /> + ); + } +); + +ManageIntegrationsCallout.displayName = 'ManageIntegrationsCallout'; 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 new file mode 100644 index 0000000000000..db5f01f2b2314 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/constants.ts @@ -0,0 +1,29 @@ +/* + * 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 { INTEGRATION_TABS } from './integration_tabs_configs'; +import type { Tab } from './types'; + +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 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 +export const MAX_CARD_HEIGHT = 127; // px +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 = '568px'; +export const WITHOUT_SEARCH_BOX_HEIGHT = '513px'; 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 new file mode 100644 index 0000000000000..04ba6d1a49c62 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.test.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 { render, waitFor } from '@testing-library/react'; +import { IntegrationsCardGridTabs } from './integration_card_grid_tabs'; +import { TestProviders } from '../../../../../common/mock/test_providers'; +import * as module from '@kbn/fleet-plugin/public'; + +jest.mock('@kbn/fleet-plugin/public'); +jest.mock('./package_list_grid'); + +jest + .spyOn(module, 'AvailablePackagesHook') + .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, + }); + expect(getByTestId('loadingPackages')).toBeInTheDocument(); + }); + + it('renders PackageListGrid when data is loaded successfully', async () => { + 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 new file mode 100644 index 0000000000000..aff790f469810 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs.tsx @@ -0,0 +1,28 @@ +/* + * 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 { 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< + { + installedIntegrationsCount: number; + isAgentRequired: boolean; + }, + { useAvailablePackages: AvailablePackagesHookType } +>( + 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..2e673d98278a3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integration_tabs_configs.ts @@ -0,0 +1,83 @@ +/* + * 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: '', + iconType: 'starFilled', + id: IntegrationTabId.recommended, + label: 'Recommended', + overflow: 'hidden', + showSearchTools: false, + // Fleet has a default sorting for integrations by category that Security Solution does not want to apply + // so we need to disable the sorting for the recommended tab to allow static ordering according to the featuredCardIds + sortByFeaturedIntegrations: false, + featuredCardIds: [ + 'epr:aws', + 'epr:gcp', + 'epr:azure', + 'epr:endpoint', + 'epr:crowdstrike', + 'epr:wiz', + 'epr:network_traffic', + 'epr:osquery_manager', + ], + }, + { + category: 'security', + id: IntegrationTabId.network, + label: 'Network', + subCategory: 'network', + showSearchTools: true, + sortByFeaturedIntegrations: true, + }, + { + category: 'security', + id: IntegrationTabId.user, + label: 'User', + subCategory: 'iam', + showSearchTools: true, + sortByFeaturedIntegrations: true, + }, + { + category: 'security', + id: IntegrationTabId.endpoint, + label: 'Endpoint', + subCategory: 'edr_xdr', + showSearchTools: true, + sortByFeaturedIntegrations: true, + }, + { + category: 'security', + id: IntegrationTabId.cloud, + label: 'Cloud', + subCategory: 'cloudsecurity_cdr', + showSearchTools: true, + sortByFeaturedIntegrations: true, + }, + { + category: 'security', + id: IntegrationTabId.threatIntel, + label: 'Threat Intel', + subCategory: 'threat_intel', + showSearchTools: true, + sortByFeaturedIntegrations: true, + }, + { + category: '', + id: IntegrationTabId.all, + label: 'All', + showSearchTools: true, + sortByFeaturedIntegrations: 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 new file mode 100644 index 0000000000000..cb47a00ed4a84 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_card.test.tsx @@ -0,0 +1,30 @@ +/* + * 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'); + +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(); + }); +}); 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..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 @@ -4,34 +4,32 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import React from 'react'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + import type { OnboardingCardComponent } from '../../../../types'; import { OnboardingCardContentPanel } from '../common/card_content_panel'; -import { CardCallOut } from '../common/card_callout'; +import { IntegrationsCardGridTabs } from './integration_card_grid_tabs'; +import { CenteredLoadingSpinner } from '../../../../../common/components/centered_loading_spinner'; +import type { IntegrationCardMetadata } from './types'; + +const isCheckCompleteMetadata = (metadata?: unknown): metadata is IntegrationCardMetadata => { + return metadata !== undefined; +}; export const IntegrationsCard: OnboardingCardComponent = ({ - setComplete, checkCompleteMetadata, // this is undefined before the first checkComplete call finishes }) => { - // TODO: implement. This is just for demo purposes + if (!isCheckCompleteMetadata(checkCompleteMetadata)) { + return ; + } + const { installedIntegrationsCount, isAgentRequired } = checkCompleteMetadata; + return ( - - - {checkCompleteMetadata ? ( - - ) : ( - - )} - - - setComplete(false)}>{'Set not complete'} - - + ); }; 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..3dd19d8868390 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/integrations_check_complete.test.ts @@ -0,0 +1,106 @@ +/* + * 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 { StartServices } from '../../../../../types'; + +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(); + const mockService = { + http: { + get: mockHttpGet, + }, + data: { + search: { + search: mockSearch, + }, + }, + } as unknown as StartServices; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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(mockService); + + expect(result).toEqual({ + isComplete: false, + metadata: { + installedIntegrationsCount: 0, + isAgentRequired: 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(mockService); + + expect(result).toEqual({ + isComplete: true, + completeBadgeText: '1 integration added', + metadata: { + installedIntegrationsCount: 1, + isAgentRequired: true, + }, + }); + }); + + it('returns isComplete as true and isAgentRequired 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(mockService); + + expect(result).toEqual({ + isComplete: true, + completeBadgeText: '2 integrations added', + metadata: { + 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 872b077222062..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 @@ -5,23 +5,63 @@ * 2.0. */ +import type { GetPackagesResponse } 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'; -import { getDummyAdditionalBadge } from './integrations_header_badges'; +import { AGENT_INDEX } from './constants'; +import type { StartServices } from '../../../../../types'; -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 ( + services: StartServices +) => { + 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({ + params: { index: AGENT_INDEX, body: { size: 1 } }, + }) + ); + + const installed = packageData?.items?.filter( + (pkg) => + pkg.status === installationStatuses.Installed || + pkg.status === installationStatuses.InstallFailed + ); + const isComplete = installed && installed.length > 0; + const agentsDataAvailable = !!agentsData?.rawResponse?.hits?.total; + const isAgentRequired = isComplete && !agentsDataAvailable; + + 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 { + isComplete, + metadata: { + installedIntegrationsCount: 0, + isAgentRequired: false, + }, + }; + } + + return { + isComplete, + completeBadgeText, + metadata: { + 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 new file mode 100644 index 0000000000000..dff0c50e61e9a --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.test.tsx @@ -0,0 +1,145 @@ +/* + * 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 * as module from '@kbn/fleet-plugin/public'; +import { DEFAULT_TAB } from './constants'; + +jest.mock('../../../onboarding_context'); +jest.mock('../../../../hooks/use_stored_state'); + +jest.mock('../../../../../common/lib/kibana', () => ({ + useNavigation: jest.fn().mockReturnValue({ + navigateTo: jest.fn(), + getAppUrl: jest.fn(), + }), +})); + +const mockPackageList = jest.fn().mockReturnValue(
); + +jest + .spyOn(module, 'PackageList') + .mockImplementation(() => Promise.resolve({ PackageListGrid: mockPackageList })); + +describe('PackageListGrid', () => { + const mockUseAvailablePackages = jest.fn(); + const mockSetTabId = jest.fn(); + const mockSetCategory = jest.fn(); + const mockSetSelectedSubCategory = jest.fn(); + const mockSetSearchTerm = jest.fn(); + const props = { + installedIntegrationsCount: 1, + isAgentRequired: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useStoredIntegrationTabId as jest.Mock).mockReturnValue([DEFAULT_TAB.id, jest.fn()]); + (useStoredIntegrationSearchTerm as jest.Mock).mockReturnValue(['', jest.fn()]); + }); + + 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', async () => { + mockUseAvailablePackages.mockReturnValue({ + isLoading: false, + filteredCards: [{ id: 'card1', name: 'Card 1', url: 'https://mock-url' }], + setCategory: mockSetCategory, + setSelectedSubCategory: mockSetSelectedSubCategory, + setSearchTerm: mockSetSearchTerm, + }); + + const { getByTestId } = render( + + ); + + await waitFor(() => { + 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'); + + act(() => { + fireEvent.click(tabButton); + }); + expect(mockSetTabId).toHaveBeenCalledWith('user'); + }); + + it('renders no search tools when showSearchTools is false', async () => { + mockUseAvailablePackages.mockReturnValue({ + isLoading: false, + filteredCards: [], + setCategory: mockSetCategory, + setSelectedSubCategory: mockSetSelectedSubCategory, + setSearchTerm: mockSetSearchTerm, + }); + + render(); + + await waitFor(() => { + 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([ + 'new search term', + mockSetSearchTermToStorage, + ]); + + mockUseAvailablePackages.mockReturnValue({ + isLoading: false, + filteredCards: [], + setCategory: mockSetCategory, + setSelectedSubCategory: mockSetSelectedSubCategory, + setSearchTerm: mockSetSearchTerm, + searchTerm: 'new search term', + }); + + 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 new file mode 100644 index 0000000000000..0747c9be25f4f --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/package_list_grid.tsx @@ -0,0 +1,200 @@ +/* + * 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, { 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'; +import { noop } from 'lodash'; + +import { css } from '@emotion/react'; +import { + useStoredIntegrationSearchTerm, + useStoredIntegrationTabId, +} from '../../../../hooks/use_stored_state'; +import { useOnboardingContext } from '../../../onboarding_context'; +import { + DEFAULT_TAB, + LOADING_SKELETON_HEIGHT, + SCROLL_ELEMENT_ID, + SEARCH_FILTER_CATEGORIES, + WITHOUT_SEARCH_BOX_HEIGHT, + WITH_SEARCH_BOX_HEIGHT, +} from './constants'; +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'; + +export interface WrapperProps { + installedIntegrationsCount: number; + isAgentRequired: boolean; + useAvailablePackages: AvailablePackagesHookType; +} + +const isIntegrationTabId = (id: string): id is IntegrationTabId => { + return Object.keys(INTEGRATION_TABS_BY_ID).includes(id); +}; + +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( + ({ installedIntegrationsCount, isAgentRequired, useAvailablePackages }: WrapperProps) => { + const { spaceId } = useOnboardingContext(); + const scrollElement = useRef(null); + const [toggleIdSelected, setSelectedTabIdToStorage] = useStoredIntegrationTabId( + spaceId, + DEFAULT_TAB.id + ); + const [searchTermFromStorage, setSearchTermToStorage] = useStoredIntegrationSearchTerm(spaceId); + const onTabChange = useCallback( + (stringId: string) => { + const id = stringId as IntegrationTabId; + scrollElement.current?.scrollTo?.(0, 0); + setSelectedTabIdToStorage(id); + }, + [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(''); + } + + 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 ( + + ); + } + 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 new file mode 100644 index 0000000000000..849e9cdd2336b --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/types.ts @@ -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. + */ +export interface Tab { + category: string; + featuredCardIds?: string[]; + iconType?: string; + id: IntegrationTabId; + label: string; + overflow?: 'hidden' | 'scroll'; + showSearchTools?: boolean; + subCategory?: string; + sortByFeaturedIntegrations: boolean; +} + +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..7a3ff6c0ca3a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.test.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 { renderHook } from '@testing-library/react-hooks'; +import { useIntegrationCardList } from './use_integration_card_list'; + +jest.mock('../../../../../common/lib/kibana', () => ({ + useNavigation: jest.fn().mockReturnValue({ + navigateTo: jest.fn(), + getAppUrl: jest.fn(), + }), +})); + +describe('useIntegrationCardList', () => { + const mockIntegrationsList = [ + { + id: 'epr:endpoint', + 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(() => { + jest.clearAllMocks(); + }); + + it('returns filtered integration cards when featuredCardIds 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 featuredCardIds are provided', () => { + const featuredCardIds = ['epr:endpoint']; + const mockFilteredCards = { + featuredCards: { + 'epr:endpoint': mockIntegrationsList[0], + }, + integrationCards: mockIntegrationsList, + }; + + const { result } = renderHook(() => + useIntegrationCardList({ + integrationsList: mockIntegrationsList, + featuredCardIds, + }) + ); + + 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 new file mode 100644 index 0000000000000..bb5da356306af --- /dev/null +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/use_integration_card_list.ts @@ -0,0 +1,133 @@ +/* + * 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[], featuredCardIds: string[]) => { + return filteredCards.reduce((acc, card) => { + if (featuredCardIds.includes(card.id)) { + acc.push(card); + } + return acc; + }, []); +}; + +const getFilteredCards = ({ + featuredCardIds, + getAppUrl, + installedIntegrationList, + integrationsList, + navigateTo, +}: { + featuredCardIds?: string[]; + getAppUrl: GetAppUrl; + installedIntegrationList?: IntegrationCardItem[]; + integrationsList: IntegrationCardItem[]; + navigateTo: NavigateTo; +}) => { + const securityIntegrationsList = integrationsList.map((card) => + addSecuritySpecificProps({ navigateTo, getAppUrl, card, installedIntegrationList }) + ); + if (!featuredCardIds) { + return { featuredCards: [], integrationCards: securityIntegrationsList }; + } + const featuredCards = extractFeaturedCards(securityIntegrationsList, featuredCardIds); + return { + featuredCards, + 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, + featuredCardIds, +}: { + integrationsList: IntegrationCardItem[]; + featuredCardIds?: string[] | undefined; +}): IntegrationCardItem[] => { + const { navigateTo, getAppUrl } = useNavigation(); + + const { featuredCards, integrationCards } = useMemo( + () => getFilteredCards({ navigateTo, getAppUrl, integrationsList, featuredCardIds }), + [navigateTo, getAppUrl, integrationsList, featuredCardIds] + ); + + if (featuredCardIds && featuredCardIds.length > 0) { + return featuredCards; + } + return integrationCards ?? []; +}; 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..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 @@ -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 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); @@ -111,22 +113,22 @@ export const useCompletedCards = (cardsGroupConfig: OnboardingGroupConfig[]) => const cardConfig = cardsWithAutoCheck.find(({ id }) => id === cardId); if (cardConfig) { - cardConfig.checkComplete?.().then((checkCompleteResult) => { + cardConfig.checkComplete?.(services).then((checkCompleteResult) => { processCardCheckCompleteResult(cardId, checkCompleteResult); }); } }, - [cardsWithAutoCheck, 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?.().then((checkCompleteResult) => { + card.checkComplete?.(services).then((checkCompleteResult) => { processCardCheckCompleteResult(card.id, checkCompleteResult); }) ); - }, [cardsWithAutoCheck, processCardCheckCompleteResult]); + }, [cardsWithAutoCheck, services, processCardCheckCompleteResult]); return { isCardComplete, 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..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,12 +7,16 @@ 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', 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 +47,24 @@ export const useStoredExpandedCardId = (spaceId: string) => `${LocalStorageKey.expandedCard}.${spaceId}`, null ); + +/** + * Stores the selected integration tab ID per space + */ +export const useStoredIntegrationTabId = ( + spaceId: string, + defaultSelectedTabId: IntegrationTabId +) => + useDefinedLocalStorage( + `${LocalStorageKey.selectedIntegrationTabId}.${spaceId}`, + defaultSelectedTabId + ); + +/** + * Stores the integration search term per space + */ +export const useStoredIntegrationSearchTerm = (spaceId: string) => + useDefinedLocalStorage( + `${LocalStorageKey.IntegrationSearchTerm}.${spaceId}`, + null + ); 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/public/onboarding/types.ts b/x-pack/plugins/security_solution/public/onboarding/types.ts index f568daa30bc0b..1f7e220a5c06b 100644 --- a/x-pack/plugins/security_solution/public/onboarding/types.ts +++ b/x-pack/plugins/security_solution/public/onboarding/types.ts @@ -8,14 +8,16 @@ import type React from 'react'; import type { IconType } from '@elastic/eui'; import type { LicenseType } from '@kbn/licensing-plugin/public'; + import type { OnboardingCardId } from './constants'; import type { RequiredCapabilities } from '../common/lib/capabilities'; +import type { StartServices } from '../types'; 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. */ @@ -62,7 +64,9 @@ export type OnboardingCardComponent = React.ComponentType<{ checkCompleteMetadata?: Record; }>; -export type OnboardingCardCheckComplete = () => Promise; +export type OnboardingCardCheckComplete = ( + services: StartServices +) => Promise; export interface OnboardingCardConfig { id: OnboardingCardId; 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, }); };