Skip to content

Commit

Permalink
[Security Solution] Adding selector list component to onboarding cards (
Browse files Browse the repository at this point in the history
elastic#199311)

## Summary

This PR addresses
[elastic#198761](elastic#198761), which
enhance onboarding card layouts for better usability and engagement.

The following updates have been implemented:

- New Card Layouts:

Applied to `alerts`, `dashboards`, and `rules` cards.
Previously, each card displayed a description on the left and a static
asset on the right.
The updated design introduces a list of selectable items on the left
side. Selecting an item updates the right-side content to display a
corresponding video.

- New Components:

`OnboardingCardContentAssetPanel`: A reusable component designed to
render children and display an asset, supporting both image and video
types.
`CardSelectorList`: A flexible component for rendering a list of
selectable items provided to it, enabling the new card interaction
behavior.

- Persistent Selection:

The current selection for each card is now saved in localStorage using
keys specific to each card type (alerts, dashboards, and rules).
This ensures the last selection is remembered, and when the card is
expanded (on initial render or later), the component will automatically
scroll to and highlight the saved selection.


https://github.com/user-attachments/assets/ffc02c3c-625f-46ec-aff0-1bb1e9b73bb3

### Checklist

Delete any items that are not applicable to this PR.

- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Sergi Massaneda <sergi.massaneda@gmail.com>
  • Loading branch information
4 people authored Jan 16, 2025
1 parent e0092ad commit 0926703
Show file tree
Hide file tree
Showing 42 changed files with 1,044 additions and 162 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import useLocalStorage from 'react-use/lib/useLocalStorage';
import type { OnboardingCardId } from '../../constants';
import type { IntegrationTabId } from '../onboarding_body/cards/integrations/types';
import type { CardSelectorListItem } from '../onboarding_body/cards/common/card_selector_list';

const LocalStorageKey = {
avcBannerDismissed: 'securitySolution.onboarding.avcBannerDismissed',
Expand All @@ -16,6 +17,7 @@ const LocalStorageKey = {
expandedCard: 'securitySolution.onboarding.expandedCard',
urlDetails: 'securitySolution.onboarding.urlDetails',
selectedIntegrationTabId: 'securitySolution.onboarding.selectedIntegrationTabId',
selectedCardItemId: 'securitySolution.onboarding.selectedCardItem',
integrationSearchTerm: 'securitySolution.onboarding.integrationSearchTerm',
} as const;

Expand Down Expand Up @@ -45,6 +47,19 @@ export const useStoredCompletedCardIds = (spaceId: string) =>
export const useStoredUrlDetails = (spaceId: string) =>
useDefinedLocalStorage<string | null>(`${LocalStorageKey.urlDetails}.${spaceId}`, null);

/**
* Stores the selected selectable card ID per space
*/
export const useStoredSelectedCardItemId = (
cardType: 'alerts' | 'dashboards' | 'rules',
spaceId: string,
defaultSelectedCardItemId: CardSelectorListItem['id']
) =>
useDefinedLocalStorage<CardSelectorListItem['id']>(
`${LocalStorageKey.selectedCardItemId}.${cardType}.${spaceId}`,
defaultSelectedCardItemId
);

/**
* Stores the selected integration tab ID per space
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,18 @@
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { AlertsCard } from './alerts_card';
import { TestProviders } from '../../../../../common/mock';
import { TestProviders } from '../../../../../common/mock/test_providers';
import { render } from '@testing-library/react';
import { OnboardingContextProvider } from '../../../onboarding_context';
import { ExperimentalFeaturesService } from '../../../../../common/experimental_features_service';

jest.mock('../../../../../common/experimental_features_service', () => ({
ExperimentalFeaturesService: { get: jest.fn() },
}));
const mockExperimentalFeatures = ExperimentalFeaturesService.get as jest.Mock;
const mockIsCardComplete = jest.fn();
const mockIsCardAvailable = jest.fn();

const props = {
setComplete: jest.fn(),
Expand All @@ -19,64 +28,45 @@ const props = {

describe('AlertsCard', () => {
beforeEach(() => {
mockExperimentalFeatures.mockReturnValue({});
jest.clearAllMocks();
});

it('description should be in the document', () => {
const { getByTestId } = render(
<TestProviders>
<AlertsCard {...props} />
<OnboardingContextProvider spaceId="default">
<AlertsCard {...props} />
</OnboardingContextProvider>
</TestProviders>
);

expect(getByTestId('alertsCardDescription')).toBeInTheDocument();
});

it('card callout should be rendered if integrations card is available but not complete', () => {
props.isCardAvailable.mockReturnValueOnce(true);
props.isCardComplete.mockReturnValueOnce(false);

const { getByText } = render(
<TestProviders>
<AlertsCard {...props} />
</TestProviders>
);

expect(getByText('To view alerts add integrations first.')).toBeInTheDocument();
});

it('card callout should not be rendered if integrations card is not available', () => {
props.isCardAvailable.mockReturnValueOnce(false);
mockIsCardAvailable.mockReturnValueOnce(false);

const { queryByText } = render(
<TestProviders>
<AlertsCard {...props} />
<OnboardingContextProvider spaceId="default">
<AlertsCard {...props} />
</OnboardingContextProvider>
</TestProviders>
);

expect(queryByText('To view alerts add integrations first.')).not.toBeInTheDocument();
});

it('card button should be disabled if integrations card is available but not complete', () => {
props.isCardAvailable.mockReturnValueOnce(true);
props.isCardComplete.mockReturnValueOnce(false);

const { getByTestId } = render(
<TestProviders>
<AlertsCard {...props} />
</TestProviders>
);

expect(getByTestId('alertsCardButton').querySelector('button')).toBeDisabled();
});

it('card button should be enabled if integrations card is complete', () => {
props.isCardAvailable.mockReturnValueOnce(true);
props.isCardComplete.mockReturnValueOnce(true);
mockIsCardAvailable.mockReturnValueOnce(true);
mockIsCardComplete.mockReturnValueOnce(true);

const { getByTestId } = render(
<TestProviders>
<AlertsCard {...props} />
<OnboardingContextProvider spaceId="default">
<AlertsCard {...props} />
</OnboardingContextProvider>
</TestProviders>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,39 @@
import React, { useCallback, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiSpacer } from '@elastic/eui';
import { SecurityPageName } from '@kbn/security-solution-navigation';
import { css } from '@emotion/css';
import { SecuritySolutionLinkButton } from '../../../../../common/components/links';
import { OnboardingCardId } from '../../../../constants';
import type { OnboardingCardComponent } from '../../../../types';
import { OnboardingCardContentImagePanel } from '../common/card_content_image_panel';
import { OnboardingCardContentAssetPanel } from '../common/card_content_asset_panel';
import { CardCallOut } from '../common/card_callout';
import { CardSubduedText } from '../common/card_subdued_text';
import alertsImageSrc from './images/alerts.png';
import * as i18n from './translations';
import type { CardSelectorListItem } from '../common/card_selector_list';
import { CardSelectorList } from '../common/card_selector_list';
import { ALERTS_CARD_ITEMS_BY_ID, ALERTS_CARD_ITEMS } from './alerts_card_config';
import { useOnboardingContext } from '../../../onboarding_context';
import { DEFAULT_ALERTS_CARD_ITEM_SELECTED } from './constants';
import { useStoredSelectedCardItemId } from '../../../hooks/use_stored_state';
import type { CardSelectorAssetListItem } from '../types';

export const AlertsCard: OnboardingCardComponent = ({
isCardComplete,
setExpandedCardId,
setComplete,
isCardAvailable,
}) => {
const { spaceId } = useOnboardingContext();
const [selectedAlertId, setSelectedAlertId] = useStoredSelectedCardItemId(
'alerts',
spaceId,
DEFAULT_ALERTS_CARD_ITEM_SELECTED.id
);
const selectedCardItem = useMemo<CardSelectorAssetListItem>(
() => ALERTS_CARD_ITEMS_BY_ID[selectedAlertId],
[selectedAlertId]
);

const isIntegrationsCardComplete = useMemo(
() => isCardComplete(OnboardingCardId.integrations),
[isCardComplete]
Expand All @@ -37,18 +55,36 @@ export const AlertsCard: OnboardingCardComponent = ({
setExpandedCardId(OnboardingCardId.integrations, { scroll: true });
}, [setExpandedCardId]);

const onSelectCard = useCallback(
(item: CardSelectorListItem) => {
setSelectedAlertId(item.id);
},
[setSelectedAlertId]
);

return (
<OnboardingCardContentImagePanel imageSrc={alertsImageSrc} imageAlt={i18n.ALERTS_CARD_TITLE}>
<OnboardingCardContentAssetPanel asset={selectedCardItem.asset}>
<EuiFlexGroup
direction="column"
gutterSize="xl"
justifyContent="flexStart"
alignItems="flexStart"
>
<EuiFlexItem grow={false}>
<EuiFlexItem
className={css`
width: 100%;
`}
>
<CardSubduedText data-test-subj="alertsCardDescription" size="s">
{i18n.ALERTS_CARD_DESCRIPTION}
</CardSubduedText>
<EuiSpacer />
<CardSelectorList
title={i18n.ALERTS_CARD_STEP_SELECTOR_TITLE}
items={ALERTS_CARD_ITEMS}
onSelect={onSelectCard}
selectedItem={selectedCardItem}
/>
{isIntegrationsCardAvailable && !isIntegrationsCardComplete && (
<>
<EuiSpacer size="m" />
Expand Down Expand Up @@ -81,7 +117,7 @@ export const AlertsCard: OnboardingCardComponent = ({
</SecuritySolutionLinkButton>
</EuiFlexItem>
</EuiFlexGroup>
</OnboardingCardContentImagePanel>
</OnboardingCardContentAssetPanel>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* 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 { i18n } from '@kbn/i18n';
import alertTimelineImageSrc from './images/alert_timeline.png';
import sessionViewImageSrc from './images/session_view.png';
import alertListImageSrc from './images/alert_list.png';
import eventAnalyzerImageSrc from './images/event_analyzer.png';
import { AlertsCardItemId } from './types';
import type { CardSelectorAssetListItem } from '../types';
import { CardAssetType } from '../types';

export const ALERTS_CARD_ITEMS: CardSelectorAssetListItem[] = [
{
id: AlertsCardItemId.list,
title: i18n.translate('xpack.securitySolution.onboarding.alertsCards.details.title', {
defaultMessage: 'Alert list and details',
}),
description: i18n.translate(
'xpack.securitySolution.onboarding.alertsCards.details.description',
{
defaultMessage: 'Sort through alerts and drill down into its details',
}
),
asset: {
type: CardAssetType.image,
source: alertListImageSrc,
alt: i18n.translate('xpack.securitySolution.onboarding.alertsCards.details.description', {
defaultMessage: 'Sort through alerts and drill down into its details',
}),
},
},
{
id: AlertsCardItemId.timeline,
title: i18n.translate('xpack.securitySolution.onboarding.alertsCards.timeline.title', {
defaultMessage: 'Investigate in Timeline',
}),
description: i18n.translate(
'xpack.securitySolution.onboarding.alertsCards.timeline.description',
{
defaultMessage: 'Streamline alert investigation with real-time visualization',
}
),
asset: {
type: CardAssetType.image,
source: alertTimelineImageSrc,
alt: i18n.translate('xpack.securitySolution.onboarding.alertsCards.timeline.description', {
defaultMessage: 'Streamline alert investigation with real-time visualization',
}),
},
},
{
id: AlertsCardItemId.analyzer,
title: i18n.translate('xpack.securitySolution.onboarding.alertsCards.analyzer.title', {
defaultMessage: 'Investigate in Analyzer',
}),
description: i18n.translate(
'xpack.securitySolution.onboarding.alertsCards.analyzer.description',
{
defaultMessage: 'Simplify alert analysis by visualizing threat detection processes',
}
),
asset: {
type: CardAssetType.image,
source: eventAnalyzerImageSrc,
alt: i18n.translate('xpack.securitySolution.onboarding.alertsCards.analyzer.description', {
defaultMessage: 'Simplify alert analysis by visualizing threat detection processes',
}),
},
},
{
id: AlertsCardItemId.sessionView,
title: i18n.translate('xpack.securitySolution.onboarding.alertsCards.sessionView.title', {
defaultMessage: 'Investigate in Session View',
}),
description: i18n.translate(
'xpack.securitySolution.onboarding.alertsCards.sessionView.description',
{
defaultMessage: 'Centralized threat analysis and response with real-time data insights',
}
),
asset: {
type: CardAssetType.image,
source: sessionViewImageSrc,
alt: i18n.translate('xpack.securitySolution.onboarding.alertsCards.sessionView.description', {
defaultMessage: 'Centralized threat analysis and response with real-time data insights',
}),
},
},
];

export const ALERTS_CARD_ITEMS_BY_ID = Object.fromEntries(
ALERTS_CARD_ITEMS.map((card) => [card.id, card])
);
Original file line number Diff line number Diff line change
@@ -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 type { CardSelectorListItem } from '../common/card_selector_list';
import { ALERTS_CARD_ITEMS } from './alerts_card_config';

export const DEFAULT_ALERTS_CARD_ITEM_SELECTED: CardSelectorListItem = ALERTS_CARD_ITEMS[0];
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const ALERTS_CARD_DESCRIPTION = i18n.translate(
'xpack.securitySolution.onboarding.alertsCard.description',
{
defaultMessage:
'Visualize, sort, filter, and investigate alerts from across your infrastructure. Examine individual alerts of interest, and discover general patterns in alert volume and severity.',
'Visualize, sort, filter, and investigate alerts from across your infrastructure.',
}
);

Expand All @@ -42,3 +42,10 @@ export const ALERTS_CARD_VIEW_ALERTS_BUTTON = i18n.translate(
defaultMessage: 'View alerts',
}
);

export const ALERTS_CARD_STEP_SELECTOR_TITLE = i18n.translate(
'xpack.securitySolution.onboarding.alertsCard.stepSelectorTitle',
{
defaultMessage: 'Here are four ways to use alerts',
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* 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 enum AlertsCardItemId {
list = 'list',
timeline = 'timeline',
analyzer = 'analyzer',
sessionView = 'session_view',
}
Loading

0 comments on commit 0926703

Please sign in to comment.