>,
+ 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,
});
};