= ({
name: i18n.OVERVIEW,
'data-test-subj': 'overviewTab',
content: (
- <>
-
-
-
- {threatDetails && threatDetails[0] && (
-
- <>
-
- {threatDetails[0].title}
-
-
- {threatDetails[0].description}
-
- >
-
- )}
-
- {renderer != null && detailsEcsData != null && (
-
-
- {i18n.ALERT_REASON}
-
-
-
- {renderer.renderRow({
- contextId: EVENT_DETAILS_CONTEXT_ID,
- data: detailsEcsData,
- isDraggable: isDraggable ?? false,
- scopeId,
- })}
-
-
- )}
-
-
-
-
-
- {showThreatSummary && (
-
+ <>
+
+
+
+ {threatDetails && threatDetails[0] && (
+
+ <>
+
+ {threatDetails[0].title}
+
+
+ {threatDetails[0].description}
+
+ >
+
+ )}
+
+ {renderer != null && detailsEcsData != null && (
+
+
+ {i18n.ALERT_REASON}
+
+
+
+ {renderer.renderRow({
+ contextId: EVENT_DETAILS_CONTEXT_ID,
+ data: detailsEcsData,
+ isDraggable: isDraggable ?? false,
+ scopeId,
+ })}
+
+
+ )}
+
+
+
+
- )}
- {isEnrichmentsLoading && (
- <>
-
- >
- )}
+ {showThreatSummary && (
+
+ )}
+
+ {isEnrichmentsLoading && (
+ <>
+
+ >
+ )}
- {basicAlertData.ruleId && maybeRule?.note && (
-
- )}
- >
+ {basicAlertData.ruleId && maybeRule?.note && (
+
+ )}
+ >
+
),
}
: undefined,
[
isAlert,
+ isTourAnchor,
browserFields,
scopeId,
data,
@@ -340,7 +347,7 @@ const EventDetailsComponent: React.FC = ({
detailsEcsData,
isDraggable,
goToTableTab,
- maybeRule?.investigation_fields,
+ maybeRule?.investigation_fields?.field_names,
maybeRule?.note,
showThreatSummary,
hostRisk,
@@ -469,23 +476,17 @@ const EventDetailsComponent: React.FC = ({
);
return (
-
- <>
-
-
- >
-
+ <>
+
+
+ >
);
};
EventDetailsComponent.displayName = 'EventDetailsComponent';
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx
index 52a6d5eb1eb42..54c30fa38a588 100644
--- a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx
@@ -7,7 +7,7 @@
import { act, render, screen } from '@testing-library/react';
import React from 'react';
-
+import { casesPluginMock } from '@kbn/cases-plugin/public/mocks';
import { TestProviders } from '../../../mock';
import { useKibana as mockUseKibana } from '../../../lib/kibana/__mocks__';
import { RelatedCases } from './related_cases';
@@ -17,13 +17,17 @@ import { useTourContext } from '../../guided_onboarding_tour';
import { AlertsCasesTourSteps } from '../../guided_onboarding_tour/tour_config';
const mockedUseKibana = mockUseKibana();
-const mockGetRelatedCases = jest.fn();
-const mockCanUseCases = jest.fn();
-jest.mock('../../guided_onboarding_tour');
+const mockCasesContract = casesPluginMock.createStartContract();
+const mockGetRelatedCases = mockCasesContract.api.getRelatedCases as jest.Mock;
+mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]);
+const mockCanUseCases = mockCasesContract.helpers.canUseCases as jest.Mock;
+mockCanUseCases.mockReturnValue(readCasesPermissions());
+
+const mockUseTourContext = useTourContext as jest.Mock;
+
jest.mock('../../../lib/kibana', () => {
const original = jest.requireActual('../../../lib/kibana');
-
return {
...original,
useToasts: jest.fn().mockReturnValue({ addWarning: jest.fn() }),
@@ -31,68 +35,63 @@ jest.mock('../../../lib/kibana', () => {
...mockedUseKibana,
services: {
...mockedUseKibana.services,
- cases: {
- api: {
- getRelatedCases: mockGetRelatedCases,
- },
- helpers: { canUseCases: mockCanUseCases },
- },
+ cases: mockCasesContract,
},
}),
};
});
+jest.mock('../../guided_onboarding_tour');
+const defaultUseTourContextValue = {
+ activeStep: AlertsCasesTourSteps.viewCase,
+ incrementStep: () => null,
+ endTourStep: () => null,
+ isTourShown: () => false,
+};
+
+jest.mock('../../guided_onboarding_tour/tour_step');
+
const eventId = '1c84d9bff4884dabe6aa1bb15f08433463b848d9269e587078dc56669550d27a';
const scrollToMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollToMock;
describe('Related Cases', () => {
beforeEach(() => {
- mockCanUseCases.mockReturnValue(readCasesPermissions());
- (useTourContext as jest.Mock).mockReturnValue({
- activeStep: AlertsCasesTourSteps.viewCase,
- incrementStep: () => null,
- endTourStep: () => null,
- isTourShown: () => false,
- });
+ mockUseTourContext.mockReturnValue(defaultUseTourContextValue);
jest.clearAllMocks();
});
+
describe('When user does not have cases read permissions', () => {
beforeEach(() => {
mockCanUseCases.mockReturnValue(noCasesPermissions());
});
+
test('should not show related cases when user does not have permissions', async () => {
await act(async () => {
- render(
-
-
-
- );
+ render(, { wrapper: TestProviders });
});
expect(screen.queryByText('cases')).toBeNull();
});
});
+
describe('When user does have case read permissions', () => {
+ beforeEach(() => {
+ mockCanUseCases.mockReturnValue(readCasesPermissions());
+ });
+
test('Should show the loading message', async () => {
+ mockGetRelatedCases.mockReturnValueOnce([]);
await act(async () => {
- mockGetRelatedCases.mockReturnValue([]);
- render(
-
-
-
- );
- expect(screen.getByText(CASES_LOADING)).toBeInTheDocument();
+ render(, { wrapper: TestProviders });
+ expect(screen.queryByText(CASES_LOADING)).toBeInTheDocument();
});
+ expect(screen.queryByText(CASES_LOADING)).not.toBeInTheDocument();
});
test('Should show 0 related cases when there are none', async () => {
+ mockGetRelatedCases.mockReturnValueOnce([]);
await act(async () => {
- mockGetRelatedCases.mockReturnValue([]);
- render(
-
-
-
- );
+ render(, { wrapper: TestProviders });
});
expect(screen.getByText(CASES_COUNT(0))).toBeInTheDocument();
@@ -100,28 +99,19 @@ describe('Related Cases', () => {
test('Should show 1 related case', async () => {
await act(async () => {
- mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]);
- render(
-
-
-
- );
+ render(, { wrapper: TestProviders });
});
expect(screen.getByText(CASES_COUNT(1))).toBeInTheDocument();
expect(screen.getByTestId('case-details-link')).toHaveTextContent('Test Case');
});
test('Should show 2 related cases', async () => {
+ mockGetRelatedCases.mockReturnValueOnce([
+ { id: '789', title: 'Test Case 1' },
+ { id: '456', title: 'Test Case 2' },
+ ]);
await act(async () => {
- mockGetRelatedCases.mockReturnValue([
- { id: '789', title: 'Test Case 1' },
- { id: '456', title: 'Test Case 2' },
- ]);
- render(
-
-
-
- );
+ render(, { wrapper: TestProviders });
});
expect(screen.getByText(CASES_COUNT(2))).toBeInTheDocument();
const cases = screen.getAllByTestId('case-details-link');
@@ -131,13 +121,8 @@ describe('Related Cases', () => {
});
test('Should not open the related cases accordion when isTourActive=false', async () => {
- mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]);
await act(async () => {
- render(
-
-
-
- );
+ render(, { wrapper: TestProviders });
});
expect(scrollToMock).not.toHaveBeenCalled();
expect(
@@ -146,19 +131,13 @@ describe('Related Cases', () => {
});
test('Should automatically open the related cases accordion when isTourActive=true', async () => {
- (useTourContext as jest.Mock).mockReturnValue({
- activeStep: AlertsCasesTourSteps.viewCase,
- incrementStep: () => null,
- endTourStep: () => null,
+ // this hook is called twice, so we can not use mockReturnValueOnce
+ mockUseTourContext.mockReturnValue({
+ ...defaultUseTourContextValue,
isTourShown: () => true,
});
- mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]);
await act(async () => {
- render(
-
-
-
- );
+ render(, { wrapper: TestProviders });
});
expect(scrollToMock).toHaveBeenCalled();
expect(
diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/__mocks__/tour_step.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/__mocks__/tour_step.tsx
new file mode 100644
index 0000000000000..fe1ce6c6b1c1d
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/__mocks__/tour_step.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 GuidedOnboardingTourStep = jest.fn(({ children }) => (
+ {children}
+));
diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx
index 2cf0153a1a3fe..a4fca35acf56b 100644
--- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour.tsx
@@ -23,6 +23,8 @@ export interface TourContextValue {
incrementStep: (tourId: SecurityStepId) => void;
isTourShown: (tourId: SecurityStepId) => boolean;
setStep: (tourId: SecurityStepId, step: AlertsCasesTourSteps) => void;
+ hidden: boolean;
+ setAllTourStepsHidden: (h: boolean) => void;
}
const initialState: TourContextValue = {
@@ -31,12 +33,19 @@ const initialState: TourContextValue = {
incrementStep: () => {},
isTourShown: () => false,
setStep: () => {},
+ hidden: false,
+ setAllTourStepsHidden: () => {},
};
const TourContext = createContext(initialState);
export const RealTourContextProvider = ({ children }: { children: ReactChild }) => {
const { guidedOnboarding } = useKibana().services;
+ const [hidden, setHidden] = useState(false);
+
+ const setAllTourStepsHidden = useCallback((h: boolean) => {
+ setHidden(h);
+ }, []);
const isRulesTourActive = useObservable(
guidedOnboarding?.guidedOnboardingApi
@@ -61,13 +70,16 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild })
const tourStatus = useMemo(
() => ({
- [SecurityStepId.rules]: isRulesTourActive,
- [SecurityStepId.alertsCases]: isAlertsCasesTourActive,
+ [SecurityStepId.rules]: { active: isRulesTourActive, hidden: false },
+ [SecurityStepId.alertsCases]: { active: isAlertsCasesTourActive, hidden: false },
}),
[isRulesTourActive, isAlertsCasesTourActive]
);
- const isTourShown = useCallback((tourId: SecurityStepId) => tourStatus[tourId], [tourStatus]);
+ const isTourShown = useCallback(
+ (tourId: SecurityStepId) => tourStatus[tourId].active,
+ [tourStatus]
+ );
const [activeStep, _setActiveStep] = useState(1);
const incrementStep = useCallback((tourId: SecurityStepId) => {
@@ -106,13 +118,15 @@ export const RealTourContextProvider = ({ children }: { children: ReactChild })
const context = useMemo(() => {
return {
+ hidden,
+ setAllTourStepsHidden,
activeStep,
endTourStep,
incrementStep,
isTourShown,
setStep,
};
- }, [activeStep, endTourStep, incrementStep, isTourShown, setStep]);
+ }, [activeStep, endTourStep, hidden, incrementStep, isTourShown, setAllTourStepsHidden, setStep]);
return {children};
};
diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts
index ca52149bfc329..dd04b76d061a8 100644
--- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts
+++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_config.ts
@@ -59,6 +59,23 @@ const defaultConfig = {
export const getTourAnchor = (step: number, tourId: SecurityStepId) =>
`tourStepAnchor-${tourId}-${step}`;
+export const hiddenWhenLeftExpandableFlyoutExpanded: Record = {
+ [SecurityStepId.alertsCases]: [
+ AlertsCasesTourSteps.pointToAlertName,
+ AlertsCasesTourSteps.expandEvent,
+ ],
+};
+
+export const hiddenWhenCaseFlyoutExpanded: Record = {
+ [SecurityStepId.alertsCases]: [
+ AlertsCasesTourSteps.pointToAlertName,
+ AlertsCasesTourSteps.expandEvent,
+ AlertsCasesTourSteps.reviewAlertDetailsFlyout,
+ AlertsCasesTourSteps.addAlertToCase,
+ AlertsCasesTourSteps.viewCase,
+ ],
+};
+
const alertsCasesConfig: StepConfig[] = [
{
...defaultConfig,
diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx
index cf693392f83c4..0490bf882edd0 100644
--- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.test.tsx
@@ -12,15 +12,38 @@ import { AlertsCasesTourSteps, SecurityStepId } from './tour_config';
import { useTourContext } from './tour';
import { mockGlobalState, TestProviders, createMockStore } from '../../mock';
import { TimelineId } from '../../../../common/types';
+import { casesPluginMock } from '@kbn/cases-plugin/public/mocks';
+import { useKibana as mockUseKibana } from '../../lib/kibana/__mocks__';
+import { useHiddenByFlyout } from './use_hidden_by_flyout';
+
+const mockedUseKibana = mockUseKibana();
+const mockCasesContract = casesPluginMock.createStartContract();
+const mockUseIsAddToCaseOpen = mockCasesContract.hooks.useIsAddToCaseOpen as jest.Mock;
+mockUseIsAddToCaseOpen.mockReturnValue(false);
+const mockUseTourContext = useTourContext as jest.Mock;
+
+jest.mock('../../lib/kibana', () => {
+ const original = jest.requireActual('../../lib/kibana');
+ return {
+ ...original,
+ useToasts: jest.fn().mockReturnValue({ addWarning: jest.fn() }),
+ useKibana: () => ({
+ ...mockedUseKibana,
+ services: {
+ ...mockedUseKibana.services,
+ cases: mockCasesContract,
+ },
+ }),
+ };
+});
jest.mock('./tour');
-const mockTourStep = jest
- .fn()
- .mockImplementation(({ children, footerAction }: EuiTourStepProps) => (
-
- {children} {footerAction}
-
- ));
+
+const useHiddenByFlyoutMock = useHiddenByFlyout as jest.Mock;
+jest.mock('./use_hidden_by_flyout', () => ({
+ useHiddenByFlyout: jest.fn(),
+}));
+
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
@@ -29,23 +52,37 @@ jest.mock('@elastic/eui', () => {
EuiTourStep: (props: any) => mockTourStep(props),
};
});
+
+const mockTourStep = jest
+ .fn()
+ .mockImplementation(({ children, footerAction }: EuiTourStepProps) => (
+
+ {children} {footerAction}
+
+ ));
+
const defaultProps = {
isTourAnchor: true,
- step: 1,
+ step: AlertsCasesTourSteps.pointToAlertName,
tourId: SecurityStepId.alertsCases,
};
const mockChildren = {'random child element'}
;
+const incrementStep = jest.fn();
+
+const defaultUseTourContextValue = {
+ activeStep: AlertsCasesTourSteps.pointToAlertName,
+ incrementStep,
+ endTourStep: jest.fn(),
+ isTourShown: jest.fn(() => true),
+ hidden: false,
+};
describe('GuidedOnboardingTourStep', () => {
- const incrementStep = jest.fn();
beforeEach(() => {
- (useTourContext as jest.Mock).mockReturnValue({
- activeStep: 1,
- incrementStep,
- isTourShown: () => true,
- });
jest.clearAllMocks();
+ mockUseTourContext.mockReturnValue(defaultUseTourContextValue);
+ useHiddenByFlyoutMock.mockReturnValue(false);
});
it('renders as a tour step', () => {
const { getByTestId } = render(
@@ -57,6 +94,17 @@ describe('GuidedOnboardingTourStep', () => {
expect(tourStep).toBeInTheDocument();
expect(header).toBeInTheDocument();
});
+ it('useHiddenByFlyout equals to true, just render children', () => {
+ useHiddenByFlyoutMock.mockReturnValue(true);
+ const { getByTestId, queryByTestId } = render(
+ {mockChildren},
+ { wrapper: TestProviders }
+ );
+ const tourStep = queryByTestId('tourStepMock');
+ const header = getByTestId('h1');
+ expect(tourStep).not.toBeInTheDocument();
+ expect(header).toBeInTheDocument();
+ });
it('isTourAnchor={false}, just render children', () => {
const { getByTestId, queryByTestId } = render(
@@ -100,19 +148,14 @@ describe('GuidedOnboardingTourStep', () => {
describe('SecurityTourStep', () => {
const { isTourAnchor: _, ...stepDefaultProps } = defaultProps;
beforeEach(() => {
- (useTourContext as jest.Mock).mockReturnValue({
- activeStep: 1,
- incrementStep: jest.fn(),
- isTourShown: () => true,
- });
+ (useTourContext as jest.Mock).mockReturnValue(defaultUseTourContextValue);
jest.clearAllMocks();
});
it('does not render if tour step does not exist', () => {
- (useTourContext as jest.Mock).mockReturnValue({
+ mockUseTourContext.mockReturnValue({
+ ...defaultUseTourContextValue,
activeStep: 99,
- incrementStep: jest.fn(),
- isTourShown: () => true,
});
render(
@@ -134,10 +177,10 @@ describe('SecurityTourStep', () => {
});
it('does not render if security tour step is not shown', () => {
- (useTourContext as jest.Mock).mockReturnValue({
+ mockUseTourContext.mockReturnValue({
+ ...defaultUseTourContextValue,
activeStep: 1,
- incrementStep: jest.fn(),
- isTourShown: () => false,
+ isTourShown: jest.fn(() => false),
});
render({mockChildren}, {
wrapper: TestProviders,
@@ -166,10 +209,9 @@ describe('SecurityTourStep', () => {
});
it('renders next button', () => {
- (useTourContext as jest.Mock).mockReturnValue({
+ mockUseTourContext.mockReturnValue({
+ ...defaultUseTourContextValue,
activeStep: 3,
- incrementStep: jest.fn(),
- isTourShown: () => true,
});
const { getByTestId } = render(
@@ -182,9 +224,8 @@ describe('SecurityTourStep', () => {
it('if a step has an anchor declared, the tour step should be a sibling of the mockChildren', () => {
(useTourContext as jest.Mock).mockReturnValue({
+ ...defaultUseTourContextValue,
activeStep: 3,
- incrementStep: jest.fn(),
- isTourShown: () => true,
});
const { container } = render(
@@ -203,10 +244,9 @@ describe('SecurityTourStep', () => {
});
it('if a step does not an anchor declared, the tour step should be the parent of the mockChildren', () => {
- (useTourContext as jest.Mock).mockReturnValue({
+ mockUseTourContext.mockReturnValue({
+ ...defaultUseTourContextValue,
activeStep: 2,
- incrementStep: jest.fn(),
- isTourShown: () => true,
});
const { container } = render(
@@ -265,9 +305,8 @@ describe('SecurityTourStep', () => {
it('does not render next button if step hideNextButton=true ', () => {
(useTourContext as jest.Mock).mockReturnValue({
+ ...defaultUseTourContextValue,
activeStep: 6,
- incrementStep: jest.fn(),
- isTourShown: () => true,
});
const { queryByTestId } = render(
diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx
index 156604160be74..4f3d06eac53d5 100644
--- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/tour_step.tsx
@@ -18,6 +18,7 @@ import { timelineDefaults } from '../../../timelines/store/defaults';
import { timelineSelectors } from '../../../timelines/store';
import { useTourContext } from './tour';
import { AlertsCasesTourSteps, SecurityStepId, securityTourConfig } from './tour_config';
+import { useHiddenByFlyout } from './use_hidden_by_flyout';
interface SecurityTourStep {
children?: React.ReactElement;
@@ -37,104 +38,113 @@ const StyledTourStep = styled(EuiTourStep) {
- const { activeStep, incrementStep, isTourShown } = useTourContext();
- const tourStep = useMemo(
- () => securityTourConfig[tourId].find((config) => config.step === step),
- [step, tourId]
- );
-
- const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
- const showTimeline = useShallowEqualSelector(
- (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show
- );
-
- const onClickNext = useCallback(
- // onClick should call incrementStep itself
- () => (onClick ? onClick() : incrementStep(tourId)),
- [incrementStep, onClick, tourId]
- );
-
- // EUI bug, will remove once bug resolve. will link issue here as soon as i have it
- const onKeyDown = useCallback((e) => {
- if (e.key === 'Enter') {
- e.stopPropagation();
+export const SecurityTourStep = React.memo(
+ ({ children, onClick, step, tourId }: SecurityTourStep) => {
+ const { activeStep, incrementStep, isTourShown } = useTourContext();
+ const tourStep = useMemo(
+ () => securityTourConfig[tourId].find((config) => config.step === step),
+ [step, tourId]
+ );
+
+ const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
+ const showTimeline = useShallowEqualSelector(
+ (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show
+ );
+ const onClickNext = useCallback(
+ // onClick should call incrementStep itself
+ () => (onClick ? onClick() : incrementStep(tourId)),
+ [incrementStep, onClick, tourId]
+ );
+
+ // EUI bug, will remove once bug resolve. will link issue here as soon as i have it
+ const onKeyDown = useCallback((e) => {
+ if (e.key === 'Enter') {
+ e.stopPropagation();
+ }
+ }, []);
+
+ // steps in Cases app are out of context.
+ // If we mount this step, we know we need to render it
+ // we are also managing the context on the siem end in the background
+ const overrideContext = isStepExternallyMounted(tourId, step);
+
+ if (
+ tourStep == null ||
+ ((step !== activeStep || !isTourShown(tourId)) && !overrideContext) ||
+ showTimeline
+ ) {
+ return children ? children : null;
}
- }, []);
-
- // steps in Cases app are out of context.
- // If we mount this step, we know we need to render it
- // we are also managing the context on the siem end in the background
- const overrideContext = isStepExternallyMounted(tourId, step);
-
- if (
- tourStep == null ||
- ((step !== activeStep || !isTourShown(tourId)) && !overrideContext) ||
- showTimeline
- ) {
- return children ? children : null;
- }
- const { anchor, content, imageConfig, dataTestSubj, hideNextButton = false, ...rest } = tourStep;
- const footerAction: EuiTourStepProps['footerAction'] = !hideNextButton ? (
-
-
-
- ) : (
- <>
- {/* Passing empty element instead of undefined. If undefined "Skip tour" button is shown, we do not want that*/}
- >
- );
-
- const commonProps = {
- ...rest,
- content: (
+ const {
+ anchor,
+ content,
+ imageConfig,
+ dataTestSubj,
+ hideNextButton = false,
+ ...rest
+ } = tourStep;
+ const footerAction: EuiTourStepProps['footerAction'] = !hideNextButton ? (
+
+
+
+ ) : (
<>
-
- {content}
-
- {imageConfig && (
- <>
-
-
- >
- )}
+ {/* Passing empty element instead of undefined. If undefined "Skip tour" button is shown, we do not want that*/}
>
- ),
- footerAction,
- // we would not have mounted this component if it was not open
- isStepOpen: true,
- // guided onboarding does not allow skipping tour through the steps
- onFinish: () => null,
- stepsTotal: securityTourConfig[tourId].length,
- panelProps: {
- 'data-test-subj': dataTestSubj,
- },
- };
-
- // tour step either needs children or an anchor element
- // see type EuiTourStepAnchorProps
- return anchor != null ? (
- <>
-
- <>{children}>
- >
- ) : children != null ? (
-
- {children}
-
- ) : null;
-};
+ );
+
+ const commonProps = {
+ ...rest,
+ content: (
+ <>
+
+ {content}
+
+ {imageConfig && (
+ <>
+
+
+ >
+ )}
+ >
+ ),
+ footerAction,
+ // we would not have mounted this component if it was not open
+ isStepOpen: true,
+ // guided onboarding does not allow skipping tour through the steps
+ onFinish: () => null,
+ stepsTotal: securityTourConfig[tourId].length,
+ panelProps: {
+ 'data-test-subj': dataTestSubj,
+ },
+ };
+
+ // tour step either needs children or an anchor element
+ // see type EuiTourStepAnchorProps
+ return anchor != null ? (
+ <>
+
+ <>{children}>
+ >
+ ) : children != null ? (
+
+ {children}
+
+ ) : null;
+ }
+);
+SecurityTourStep.displayName = 'SecurityTourStep';
interface GuidedOnboardingTourStep extends SecurityTourStep {
// can be false if the anchor is an iterative element
@@ -145,11 +155,30 @@ interface GuidedOnboardingTourStep extends SecurityTourStep {
// wraps tour anchor component
// and gives the tour step itself a place to mount once it is active
// mounts the tour step with a delay to ensure the anchor will render first
-export const GuidedOnboardingTourStep = ({
- children,
- // can be false if the anchor is an iterative element
- // do not use this as an "is tour active" check, the SecurityTourStep checks that anyway
- isTourAnchor = true,
- ...props
-}: GuidedOnboardingTourStep) =>
- isTourAnchor ? {children} : <>{children}>;
+export const GuidedOnboardingTourStep = React.memo(
+ ({
+ children,
+ // can be false if the anchor is an iterative element
+ // do not use this as an "is tour active" check, the SecurityTourStep checks that anyway
+ isTourAnchor = true,
+ ...props
+ }: GuidedOnboardingTourStep) => {
+ return isTourAnchor ? (
+ {children}
+ ) : (
+ <>{children}>
+ );
+ }
+);
+GuidedOnboardingTourStep.displayName = 'GuidedOnboardingTourStep';
+
+const SecurityTourStepAnchor = React.memo(({ children, ...props }: SecurityTourStep) => {
+ const { hidden: allStepsHidden } = useTourContext();
+ const hiddenByFlyout = useHiddenByFlyout({ tourId: props.tourId, step: props.step });
+ return !allStepsHidden && !hiddenByFlyout ? (
+ {children}
+ ) : (
+ <>{children}>
+ );
+});
+SecurityTourStepAnchor.displayName = 'SecurityTourStepAnchor';
diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_hidden_by_flyout.ts b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_hidden_by_flyout.ts
new file mode 100644
index 0000000000000..c0c96be21610f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/use_hidden_by_flyout.ts
@@ -0,0 +1,60 @@
+/*
+ * 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 { useLocation } from 'react-router-dom';
+import type { RisonValue } from '@kbn/rison';
+import type { AlertsCasesTourSteps } from './tour_config';
+import {
+ hiddenWhenCaseFlyoutExpanded,
+ hiddenWhenLeftExpandableFlyoutExpanded,
+ SecurityStepId,
+} from './tour_config';
+import { useKibana } from '../../lib/kibana';
+import { URL_PARAM_KEY } from '../../hooks/use_url_state';
+import { getObjectFromQueryString } from '../../utils/global_query_string/helpers';
+
+interface UseHiddenByFlyoutProps {
+ tourId: SecurityStepId;
+ step: AlertsCasesTourSteps;
+}
+
+/*
+ ** To check if given Guided tour step should be hidden when the LEFT expandable flyout
+ ** or any case modal is opened
+ */
+export const useHiddenByFlyout = ({ tourId, step }: UseHiddenByFlyoutProps) => {
+ const { useIsAddToCaseOpen } = useKibana().services.cases.hooks;
+ const isAddToCaseOpen = useIsAddToCaseOpen();
+
+ const { search } = useLocation();
+
+ const expandableFlyoutKey = useMemo(
+ () =>
+ getObjectFromQueryString<{ left: RisonValue; right: RisonValue }>(
+ URL_PARAM_KEY.flyout,
+ search
+ ),
+ [search]
+ );
+
+ const isExpandableFlyoutExpanded = expandableFlyoutKey?.left;
+
+ const hiddenWhenExpandableFlyoutOpened = useMemo(
+ () =>
+ isExpandableFlyoutExpanded &&
+ hiddenWhenLeftExpandableFlyoutExpanded[SecurityStepId.alertsCases]?.includes(step),
+ [isExpandableFlyoutExpanded, step]
+ );
+
+ const hiddenWhenCasesModalFlyoutExpanded = useMemo(
+ () => isAddToCaseOpen && hiddenWhenCaseFlyoutExpanded[tourId]?.includes(step),
+ [isAddToCaseOpen, tourId, step]
+ );
+
+ return hiddenWhenExpandableFlyoutOpened || hiddenWhenCasesModalFlyoutExpanded;
+};
diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx
index c2f6f8af965f8..9c79d28ce2069 100644
--- a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.test.tsx
@@ -19,7 +19,12 @@ import { SecurityStepId } from '../guided_onboarding_tour/tour_config';
import { Actions } from './actions';
import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '../user_privileges/user_privileges_context';
import { useUserPrivileges } from '../user_privileges';
+import { useHiddenByFlyout } from '../guided_onboarding_tour/use_hidden_by_flyout';
+const useHiddenByFlyoutMock = useHiddenByFlyout as jest.Mock;
+jest.mock('../guided_onboarding_tour/use_hidden_by_flyout', () => ({
+ useHiddenByFlyout: jest.fn(),
+}));
jest.mock('../guided_onboarding_tour');
jest.mock('../user_privileges');
jest.mock('../../../detections/components/user_info', () => ({
@@ -203,6 +208,19 @@ describe('Actions', () => {
expect(wrapper.find(SecurityTourStep).exists()).toEqual(true);
});
+ test('if left expandable flyout is expanded, SecurityTourStep not active', () => {
+ useHiddenByFlyoutMock.mockReturnValue(true);
+
+ const wrapper = mount(
+
+
+
+ );
+
+ expect(wrapper.find(GuidedOnboardingTourStep).exists()).toEqual(true);
+ expect(wrapper.find(SecurityTourStep).exists()).toEqual(false);
+ });
+
test('on expand event click and SecurityTourStep is active, incrementStep', () => {
const wrapper = mount(
diff --git a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.test.tsx
new file mode 100644
index 0000000000000..69f1b5fcbf4e0
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.test.tsx
@@ -0,0 +1,127 @@
+/*
+ * 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 {
+ isDetectionsPages,
+ getQueryStringFromLocation,
+ getParamFromQueryString,
+ getObjectFromQueryString,
+ useGetInitialUrlParamValue,
+ encodeQueryString,
+ useReplaceUrlParams,
+ createHistoryEntry,
+} from './helpers';
+import { renderHook } from '@testing-library/react-hooks';
+import { createMemoryHistory } from 'history';
+import { Router } from 'react-router-dom';
+import React from 'react';
+
+const flyoutString =
+ "(left:(id:document-details-left,params:(id:'04cf6ea970ab702721f17755b718c1296b5f8b5fcf7b74b053eab9bc885ceb9c',indexName:.internal.alerts-security.alerts-default-000001,scopeId:alerts-page)),right:(id:document-details-right,params:(id:'04cf6ea970ab702721f17755b718c1296b5f8b5fcf7b74b053eab9bc885ceb9c',indexName:.internal.alerts-security.alerts-default-000001,scopeId:alerts-page)))";
+const testString = `sourcerer=(default:(id:security-solution-default,selectedPatterns:!(.alerts-security.alerts-default)))&timerange=(global:(linkTo:!(),timerange:(from:%272024-05-14T23:00:00.000Z%27,fromStr:now%2Fd,kind:relative,to:%272024-05-15T22:59:59.999Z%27,toStr:now%2Fd)),timeline:(linkTo:!(),timerange:(from:%272024-05-14T09:32:36.347Z%27,kind:absolute,to:%272024-05-15T09:32:36.347Z%27)))&timeline=(activeTab:query,graphEventId:%27%27,isOpen:!f)&pageFilters=!((exclude:!f,existsSelected:!f,fieldName:kibana.alert.workflow_status,hideActionBar:!t,selectedOptions:!(open),title:Status),(exclude:!f,existsSelected:!f,fieldName:kibana.alert.severity,hideActionBar:!t,selectedOptions:!(),title:Severity),(exclude:!f,existsSelected:!f,fieldName:user.name,hideActionBar:!f,selectedOptions:!(),title:User),(exclude:!f,existsSelected:!f,fieldName:host.name,hideActionBar:!f,selectedOptions:!(),title:Host))&flyout=${flyoutString}&timelineFlyout=()`;
+
+const flyoutObject = {
+ left: {
+ id: 'document-details-left',
+ params: {
+ id: '04cf6ea970ab702721f17755b718c1296b5f8b5fcf7b74b053eab9bc885ceb9c',
+ indexName: '.internal.alerts-security.alerts-default-000001',
+ scopeId: 'alerts-page',
+ },
+ },
+ right: {
+ id: 'document-details-right',
+ params: {
+ id: '04cf6ea970ab702721f17755b718c1296b5f8b5fcf7b74b053eab9bc885ceb9c',
+ indexName: '.internal.alerts-security.alerts-default-000001',
+ scopeId: 'alerts-page',
+ },
+ },
+};
+
+describe('helpers', () => {
+ describe('isDetectionsPages', () => {
+ it('returns true for detections pages', () => {
+ expect(isDetectionsPages('alerts')).toBe(true);
+ expect(isDetectionsPages('rules')).toBe(true);
+ expect(isDetectionsPages('rules-add')).toBe(true);
+ expect(isDetectionsPages('rules-create')).toBe(true);
+ expect(isDetectionsPages('exceptions')).toBe(true);
+ });
+
+ it('returns false for non-detections pages', () => {
+ expect(isDetectionsPages('otherPage')).toBe(false);
+ });
+ });
+
+ describe('getQueryStringFromLocation', () => {
+ it('returns the query string without the leading "?"', () => {
+ expect(getQueryStringFromLocation('?param=value')).toBe('param=value');
+ });
+ });
+
+ describe('getParamFromQueryString', () => {
+ it('returns the value of the specified query parameter', () => {
+ expect(getParamFromQueryString(testString, 'flyout')).toBe(flyoutString);
+ });
+
+ it('returns undefined if the query parameter is not found', () => {
+ expect(getParamFromQueryString(testString, 'param')).toBeUndefined();
+ });
+
+ it('returns the first value if the query parameter is an array', () => {
+ const queryString = 'param1=value1¶m1=value2';
+ expect(getParamFromQueryString(queryString, 'param1')).toBe('value1');
+ });
+ });
+
+ describe('getObjectFromQueryString', () => {
+ it('returns the decoded value of the specified query parameter', () => {
+ expect(getObjectFromQueryString('flyout', testString)).toEqual(flyoutObject);
+ });
+
+ it('returns null if the query parameter is not found', () => {
+ expect(getObjectFromQueryString('param', testString)).toBeNull();
+ });
+ });
+
+ describe('useGetInitialUrlParamValue', () => {
+ it('returns a function that gets the initial URL parameter value', () => {
+ window.history.pushState({}, '', `?${testString}`);
+ const { result } = renderHook(() => useGetInitialUrlParamValue('flyout'));
+ expect(result.current()).toEqual(flyoutObject);
+ });
+ });
+
+ describe('encodeQueryString', () => {
+ it('returns an encoded query string from the given parameters', () => {
+ const params = { param1: 'value1', param2: 'value2' };
+ expect(encodeQueryString(params)).toBe('param1=value1¶m2=value2');
+ });
+ });
+
+ describe('useReplaceUrlParams', () => {
+ it('replaces URL parameters correctly', () => {
+ const history = createMemoryHistory();
+ const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+ {children}
+ );
+ const { result } = renderHook(() => useReplaceUrlParams(), { wrapper: Wrapper });
+
+ window.history.pushState({}, '', '?param1=value1');
+ result.current({ param1: 'value2' });
+ expect(history.location.search).toBe('?param1=value2');
+ });
+ });
+
+ describe('createHistoryEntry', () => {
+ it('creates a new history entry', () => {
+ const initialHistoryLength = window.history.length;
+ createHistoryEntry();
+ expect(window.history.length).toBe(initialHistoryLength + 1);
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts
index 1be01959e2d0a..af3cd8161a778 100644
--- a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts
+++ b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts
@@ -33,6 +33,17 @@ export const getParamFromQueryString = (
return Array.isArray(queryParam) ? queryParam[0] : queryParam;
};
+export const getObjectFromQueryString = (
+ urlParamKey: string,
+ search?: string
+) => {
+ const rawParamValue = getParamFromQueryString(
+ getQueryStringFromLocation(search ?? window.location.search),
+ urlParamKey
+ );
+ return safeDecode(rawParamValue ?? '') as State | null;
+};
+
/**
*
* Gets the value of the URL param from the query string.
@@ -44,15 +55,10 @@ export const useGetInitialUrlParamValue = (
): (() => State | null) => {
// window.location.search provides the most updated representation of the url search.
// It also guarantees that we don't overwrite URL param managed outside react-router.
- const getInitialUrlParamValue = useCallback((): State | null => {
- const rawParamValue = getParamFromQueryString(
- getQueryStringFromLocation(window.location.search),
- urlParamKey
- );
- const paramValue = safeDecode(rawParamValue ?? '') as State | null;
-
- return paramValue;
- }, [urlParamKey]);
+ const getInitialUrlParamValue = useCallback(
+ (): State | null => getObjectFromQueryString(urlParamKey),
+ [urlParamKey]
+ );
return getInitialUrlParamValue;
};
diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx
index 85892f4ba5b53..40c855341a1e9 100644
--- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx
@@ -9,6 +9,7 @@ import { useCallback, useState } from 'react';
import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import type { inputsModel } from '../../../../common/store';
+import { useTourContext } from '../../../../common/components/guided_onboarding_tour';
interface UseExceptionFlyoutProps {
refetch?: inputsModel.Refetch;
@@ -32,20 +33,26 @@ export const useExceptionFlyout = ({
onRuleChange,
isActiveTimelines,
}: UseExceptionFlyoutProps): UseExceptionFlyout => {
+ const { setAllTourStepsHidden } = useTourContext();
const [openAddExceptionFlyout, setOpenAddExceptionFlyout] = useState(false);
const [exceptionFlyoutType, setExceptionFlyoutType] = useState(
null
);
- const onAddExceptionTypeClick = useCallback((exceptionListType?: ExceptionListTypeEnum): void => {
- setExceptionFlyoutType(exceptionListType ?? null);
- setOpenAddExceptionFlyout(true);
- }, []);
+ const onAddExceptionTypeClick = useCallback(
+ (exceptionListType?: ExceptionListTypeEnum): void => {
+ setExceptionFlyoutType(exceptionListType ?? null);
+ setAllTourStepsHidden(true);
+ setOpenAddExceptionFlyout(true);
+ },
+ [setAllTourStepsHidden]
+ );
const onAddExceptionCancel = useCallback(() => {
setExceptionFlyoutType(null);
+ setAllTourStepsHidden(false);
setOpenAddExceptionFlyout(false);
- }, []);
+ }, [setAllTourStepsHidden]);
const onAddExceptionConfirm = useCallback(
(didRuleChange: boolean, didCloseAlert: boolean, didBulkCloseAlert) => {
@@ -55,9 +62,10 @@ export const useExceptionFlyout = ({
if (onRuleChange != null && didRuleChange) {
onRuleChange();
}
+ setAllTourStepsHidden(false);
setOpenAddExceptionFlyout(false);
},
- [onRuleChange, refetch, isActiveTimelines]
+ [refetch, isActiveTimelines, onRuleChange, setAllTourStepsHidden]
);
return {
diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx
index ff1ef27195e69..ce5126664fb14 100644
--- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx
@@ -49,6 +49,7 @@ jest.mock('../user_info', () => ({
}));
jest.mock('../../../common/lib/kibana');
+jest.mock('../../../common/components/guided_onboarding_tour/tour_step');
jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges', () => ({
useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }),
diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx
index 20afb552e045a..5abe8a3dfbd93 100644
--- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx
@@ -29,6 +29,7 @@ jest.mock('../../../common/containers/sourcerer', () => ({
indicesExist: true,
}),
}));
+jest.mock('../../../common/components/guided_onboarding_tour/tour_step');
describe('RenderCellValue', () => {
const columnId = '@timestamp';
@@ -82,4 +83,21 @@ describe('RenderCellValue', () => {
expect(wrapper.find(DefaultCellRenderer).props()).toEqual(props);
});
+
+ test('it renders a GuidedOnboardingTourStep', () => {
+ const RenderCellValue = getRenderCellValueHook({
+ scopeId: SourcererScopeName.default,
+ tableId: TableId.test,
+ });
+
+ const wrapper = mount(
+
+
+
+
+
+ );
+
+ expect(wrapper.find('[data-test-subj="GuidedOnboardingTourStep"]').exists()).toEqual(true);
+ });
});
diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx
index 413e9beba6015..3262e0bee184e 100644
--- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx
+++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx
@@ -151,27 +151,27 @@ export const RenderCellValue: React.FC
);
}, [
- isTourAnchor,
- finalData,
- browserFieldsByName,
header,
columnId,
+ browserFieldsByName,
+ columnHeaders,
ecsData,
- linkValues,
- rowRenderers,
+ isTourAnchor,
+ browserFields,
+ finalData,
+ eventId,
isDetails,
- isExpandable,
isDraggable,
+ isExpandable,
isExpanded,
+ linkValues,
+ rowIndex,
colIndex,
- eventId,
+ rowRenderers,
setCellProps,
+ tableId,
truncate,
context,
- tableId,
- browserFields,
- rowIndex,
- columnHeaders,
]);
return columnId === SIGNAL_RULE_NAME_FIELD_NAME && actualSuppressionCount ? (
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx
index bef7b15a7a971..13df33a2deb1b 100644
--- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/related_cases.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import React from 'react';
+import React, { useMemo } from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { EuiInMemoryTable } from '@elastic/eui';
import type { RelatedCase } from '@kbn/cases-plugin/common';
@@ -21,7 +21,7 @@ import { ExpandablePanel } from '../../../shared/components/expandable_panel';
const ICON = 'warning';
-const columns: Array> = [
+const getColumns: (data: RelatedCase[]) => Array> = (data) => [
{
field: 'title',
name: (
@@ -30,13 +30,16 @@ const columns: Array> = [
defaultMessage="Name"
/>
),
- render: (value: string, caseData: RelatedCase) => (
-
-
- {caseData.title}
-
-
- ),
+ render: (value: string, caseData: RelatedCase) => {
+ const index = data.findIndex((d) => d.id === caseData.id);
+ return (
+
+
+ {caseData.title}
+
+
+ );
+ },
},
{
field: 'status',
@@ -63,6 +66,7 @@ export interface RelatedCasesProps {
*/
export const RelatedCases: React.FC = ({ eventId }) => {
const { loading, error, data, dataCount } = useFetchRelatedCases({ eventId });
+ const columns = useMemo(() => getColumns(data), [data]);
if (error) {
return null;
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx
index bb4fcdc79bb83..2ae680fc54ba9 100644
--- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.test.tsx
@@ -40,6 +40,8 @@ import {
EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID,
EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID,
} from '../../../shared/components/test_ids';
+import { useTourContext } from '../../../../common/components/guided_onboarding_tour';
+import { AlertsCasesTourSteps } from '../../../../common/components/guided_onboarding_tour/tour_config';
jest.mock('../../shared/hooks/use_show_related_alerts_by_ancestry');
jest.mock('../../shared/hooks/use_show_related_alerts_by_same_source_event');
@@ -100,11 +102,24 @@ jest.mock('../../../../timelines/containers/use_timeline_data_filters', () => ({
}));
const mockUseTimelineDataFilters = useTimelineDataFilters as jest.Mock;
+jest.mock('../../../../common/components/guided_onboarding_tour', () => ({
+ useTourContext: jest.fn(),
+}));
+
const originalEventId = 'originalEventId';
describe('', () => {
beforeAll(() => {
jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue);
+ jest.mocked(useTourContext).mockReturnValue({
+ hidden: false,
+ setAllTourStepsHidden: jest.fn(),
+ activeStep: AlertsCasesTourSteps.viewCase,
+ endTourStep: jest.fn(),
+ incrementStep: jest.fn(),
+ isTourShown: jest.fn(),
+ setStep: jest.fn(),
+ });
mockUseTimelineDataFilters.mockReturnValue({ selectedPatterns: ['index'] });
});
@@ -211,4 +226,24 @@ describe('', () => {
},
});
});
+
+ it('should navigate to the left section Insights tab automatically when active step is "view case"', () => {
+ render(
+
+
+
+
+
+ );
+
+ expect(flyoutContextValue.openLeftPanel).toHaveBeenCalledWith({
+ id: DocumentDetailsLeftPanelKey,
+ path: { tab: LeftPanelInsightsTab, subTab: CORRELATIONS_TAB_ID },
+ params: {
+ id: panelContextValue.eventId,
+ indexName: panelContextValue.indexName,
+ scopeId: panelContextValue.scopeId,
+ },
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx
index e6bf84f039c52..2125eb47316ae 100644
--- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/correlations_overview.tsx
@@ -6,7 +6,7 @@
*/
import { get } from 'lodash';
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect } from 'react';
import { EuiFlexGroup } from '@elastic/eui';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { FormattedMessage } from '@kbn/i18n-react';
@@ -30,6 +30,11 @@ import { LeftPanelInsightsTab } from '../../left';
import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details';
import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters';
import { isActiveTimeline } from '../../../../helpers';
+import { useTourContext } from '../../../../common/components/guided_onboarding_tour';
+import {
+ AlertsCasesTourSteps,
+ SecurityStepId,
+} from '../../../../common/components/guided_onboarding_tour/tour_config';
/**
* Correlations section under Insights section, overview tab.
@@ -40,6 +45,7 @@ export const CorrelationsOverview: React.FC = () => {
const { dataAsNestedObject, eventId, indexName, getFieldsData, scopeId, isPreview } =
useRightPanelContext();
const { openLeftPanel } = useExpandableFlyoutApi();
+ const { isTourShown, activeStep } = useTourContext();
const { selectedPatterns } = useTimelineDataFilters(isActiveTimeline(scopeId));
@@ -58,6 +64,12 @@ export const CorrelationsOverview: React.FC = () => {
});
}, [eventId, openLeftPanel, indexName, scopeId]);
+ useEffect(() => {
+ if (isTourShown(SecurityStepId.alertsCases) && activeStep === AlertsCasesTourSteps.viewCase) {
+ goToCorrelationsTab();
+ }
+ }, [activeStep, goToCorrelationsTab, isTourShown]);
+
const { show: showAlertsByAncestry, documentId } = useShowRelatedAlertsByAncestry({
getFieldsData,
dataAsNestedObject,
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx
index 81f9c6d54457e..ebba19bd2d648 100644
--- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx
@@ -29,6 +29,7 @@ import { useAlertPrevalence } from '../../../../common/containers/alerts/use_ale
import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score';
import { useExpandSection } from '../hooks/use_expand_section';
import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters';
+import { useTourContext } from '../../../../common/components/guided_onboarding_tour';
jest.mock('../../../../common/containers/alerts/use_alert_prevalence');
@@ -96,6 +97,11 @@ jest.mock('../hooks/use_fetch_threat_intelligence');
jest.mock('../../shared/hooks/use_prevalence');
+const mockUseTourContext = useTourContext as jest.Mock;
+jest.mock('../../../../common/components/guided_onboarding_tour', () => ({
+ useTourContext: jest.fn().mockReturnValue({ activeStep: 1, isTourShown: jest.fn(() => true) }),
+}));
+
const renderInsightsSection = (contextValue: RightPanelContext) =>
render(
@@ -163,6 +169,20 @@ describe('', () => {
expect(wrapper.getByTestId(INSIGHTS_CONTENT_TEST_ID)).toBeVisible();
});
+ it('should render the component expanded if guided onboarding tour is shown', () => {
+ (useExpandSection as jest.Mock).mockReturnValue(false);
+ mockUseTourContext.mockReturnValue({ activeStep: 7, isTourShown: jest.fn(() => true) });
+
+ const contextValue = {
+ eventId: 'some_Id',
+ dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,
+ getFieldsData: mockGetFieldsData,
+ } as unknown as RightPanelContext;
+
+ const wrapper = renderInsightsSection(contextValue);
+ expect(wrapper.getByTestId(INSIGHTS_CONTENT_TEST_ID)).toBeVisible();
+ });
+
it('should render all children when event kind is signal', () => {
(useExpandSection as jest.Mock).mockReturnValue(true);
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx
index 831124c5539e2..7ea4b67734ae4 100644
--- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.tsx
@@ -18,6 +18,11 @@ import { ExpandableSection } from './expandable_section';
import { useRightPanelContext } from '../context';
import { getField } from '../../shared/utils';
import { EventKind } from '../../shared/constants/event_kinds';
+import { useTourContext } from '../../../../common/components/guided_onboarding_tour';
+import {
+ AlertsCasesTourSteps,
+ SecurityStepId,
+} from '../../../../common/components/guided_onboarding_tour/tour_config';
const KEY = 'insights';
@@ -28,7 +33,12 @@ export const InsightsSection = memo(() => {
const { getFieldsData } = useRightPanelContext();
const eventKind = getField(getFieldsData('event.kind'));
- const expanded = useExpandSection({ title: KEY, defaultValue: false });
+ const { activeStep, isTourShown } = useTourContext();
+ const isGuidedOnboardingTourShown =
+ isTourShown(SecurityStepId.alertsCases) && activeStep === AlertsCasesTourSteps.viewCase;
+
+ const expanded =
+ useExpandSection({ title: KEY, defaultValue: false }) || isGuidedOnboardingTourShown;
return (
render(
@@ -50,10 +56,11 @@ describe('', () => {
services: {
...mockedUseKibana.services,
storage: storageMock,
+ cases: mockCasesContract,
},
});
(useIsTimelineFlyoutOpen as jest.Mock).mockReturnValue(false);
-
+ (useTourContext as jest.Mock).mockReturnValue({ isTourShown: jest.fn(() => false) });
storageMock.clear();
});
@@ -82,7 +89,19 @@ describe('', () => {
expect(queryByText('Next')).not.toBeInTheDocument();
});
- it('should not render tour for non-alerts', () => {
+ it('should not render tour when guided onboarding tour is active', () => {
+ (useTourContext as jest.Mock).mockReturnValue({ isTourShown: jest.fn(() => true) });
+ const { queryByText, queryByTestId } = renderRightPanelTour({
+ ...mockContextValue,
+ getFieldsData: () => '',
+ });
+
+ expect(queryByTestId(`${FLYOUT_TOUR_TEST_ID}-1`)).not.toBeInTheDocument();
+ expect(queryByText('Next')).not.toBeInTheDocument();
+ });
+
+ it('should not render tour when case modal is open', () => {
+ mockUseIsAddToCaseOpen.mockReturnValue(true);
const { queryByText, queryByTestId } = renderRightPanelTour({
...mockContextValue,
getFieldsData: () => '',
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.tsx
index 5003139a92577..9fd7219007dd8 100644
--- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/tour.tsx
@@ -20,18 +20,32 @@ import {
} from '../../shared/constants/panel_keys';
import { EventKind } from '../../shared/constants/event_kinds';
import { useIsTimelineFlyoutOpen } from '../../shared/hooks/use_is_timeline_flyout_open';
+import { useTourContext } from '../../../../common/components/guided_onboarding_tour/tour';
+import { SecurityStepId } from '../../../../common/components/guided_onboarding_tour/tour_config';
+import { useKibana } from '../../../../common/lib/kibana';
/**
* Guided tour for the right panel in details flyout
*/
export const RightPanelTour = memo(() => {
+ const { useIsAddToCaseOpen } = useKibana().services.cases.hooks;
+
+ const casesFlyoutExpanded = useIsAddToCaseOpen();
+
+ const { isTourShown: isGuidedOnboardingTourShown } = useTourContext();
+
const { openLeftPanel, openRightPanel } = useExpandableFlyoutApi();
const { eventId, indexName, scopeId, isPreview, getFieldsData } = useRightPanelContext();
const eventKind = getField(getFieldsData('event.kind'));
const isAlert = eventKind === EventKind.signal;
const isTimelineFlyoutOpen = useIsTimelineFlyoutOpen();
- const showTour = isAlert && !isPreview && !isTimelineFlyoutOpen;
+ const showTour =
+ isAlert &&
+ !isPreview &&
+ !isTimelineFlyoutOpen &&
+ !isGuidedOnboardingTourShown(SecurityStepId.alertsCases) &&
+ !casesFlyoutExpanded;
const goToLeftPanel = useCallback(() => {
openLeftPanel({
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.test.tsx
new file mode 100644
index 0000000000000..81f8e14b8b981
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.test.tsx
@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import React from 'react';
+
+import { render } from '@testing-library/react';
+import { PanelHeader } from './header';
+import { allThreeTabs } from './hooks/use_tabs';
+import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step';
+import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
+
+jest.mock('./context', () => ({
+ useRightPanelContext: jest.fn().mockReturnValue({ dataFormattedForFieldBrowser: [] }),
+}));
+jest.mock('../../../timelines/components/side_panel/event_details/helpers', () => ({
+ useBasicDataFromDetailsData: jest.fn(),
+}));
+jest.mock('../../../common/components/guided_onboarding_tour/tour_step', () => ({
+ GuidedOnboardingTourStep: jest.fn().mockReturnValue(),
+}));
+
+jest.mock('./components/alert_header_title', () => ({
+ AlertHeaderTitle: jest.fn().mockReturnValue(),
+}));
+
+jest.mock('./components/event_header_title', () => ({
+ EventHeaderTitle: jest.fn().mockReturnValue(),
+}));
+
+const mockUseBasicDataFromDetailsData = useBasicDataFromDetailsData as jest.Mock;
+const mockGuidedOnboardingTourStep = GuidedOnboardingTourStep as unknown as jest.Mock;
+
+describe('PanelHeader', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render tab name', () => {
+ mockUseBasicDataFromDetailsData.mockReturnValue({ isAlert: false });
+ const { getByText } = render(
+
+ );
+ expect(GuidedOnboardingTourStep).not.toBeCalled();
+ expect(getByText('Overview')).toBeInTheDocument();
+ });
+
+ it('should render event header title when isAlert equals false', () => {
+ mockUseBasicDataFromDetailsData.mockReturnValue({ isAlert: false });
+ const { queryByTestId } = render(
+
+ );
+ expect(queryByTestId('alert-header')).not.toBeInTheDocument();
+ expect(queryByTestId('event-header')).toBeInTheDocument();
+ });
+
+ it('should render alert header title when isAlert equals true', () => {
+ mockUseBasicDataFromDetailsData.mockReturnValue({ isAlert: true });
+ const { queryByTestId } = render(
+
+ );
+ expect(queryByTestId('alert-header')).toBeInTheDocument();
+ expect(queryByTestId('event-header')).not.toBeInTheDocument();
+ });
+
+ it('should render tab name with guided onboarding tour info', () => {
+ mockUseBasicDataFromDetailsData.mockReturnValue({ isAlert: true });
+ render(
+
+ );
+ expect(mockGuidedOnboardingTourStep.mock.calls[0][0].isTourAnchor).toBe(true);
+ expect(mockGuidedOnboardingTourStep.mock.calls[0][0].step).toBe(3);
+ expect(mockGuidedOnboardingTourStep.mock.calls[0][0].tourId).toBe('alertsCases');
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx
index 70c27e18faa23..b85476d2679fc 100644
--- a/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/header.tsx
@@ -7,7 +7,7 @@
import { EuiSpacer, EuiTab } from '@elastic/eui';
import type { FC } from 'react';
-import React, { memo } from 'react';
+import React, { memo, useMemo } from 'react';
import type { RightPanelPaths } from '.';
import type { RightPanelTabType } from './tabs';
import { FlyoutHeader } from '../../shared/components/flyout_header';
@@ -16,6 +16,12 @@ import { AlertHeaderTitle } from './components/alert_header_title';
import { EventHeaderTitle } from './components/event_header_title';
import { useRightPanelContext } from './context';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
+import {
+ AlertsCasesTourSteps,
+ getTourAnchor,
+ SecurityStepId,
+} from '../../../common/components/guided_onboarding_tour/tour_config';
+import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step';
export interface PanelHeaderProps {
/**
@@ -38,16 +44,48 @@ export const PanelHeader: FC = memo(
const { dataFormattedForFieldBrowser } = useRightPanelContext();
const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
const onSelectedTabChanged = (id: RightPanelPaths) => setSelectedTabId(id);
- const renderTabs = tabs.map((tab, index) => (
- onSelectedTabChanged(tab.id)}
- isSelected={tab.id === selectedTabId}
- key={index}
- data-test-subj={tab['data-test-subj']}
- >
- {tab.name}
-
- ));
+
+ const tourAnchor = useMemo(
+ () =>
+ isAlert
+ ? {
+ 'tour-step': getTourAnchor(
+ AlertsCasesTourSteps.reviewAlertDetailsFlyout,
+ SecurityStepId.alertsCases
+ ),
+ }
+ : {},
+ [isAlert]
+ );
+
+ const renderTabs = tabs.map((tab, index) =>
+ isAlert && tab.id === 'overview' ? (
+
+ onSelectedTabChanged(tab.id)}
+ isSelected={tab.id === selectedTabId}
+ key={index}
+ data-test-subj={tab['data-test-subj']}
+ {...tourAnchor}
+ >
+ {tab.name}
+
+
+ ) : (
+ onSelectedTabChanged(tab.id)}
+ isSelected={tab.id === selectedTabId}
+ key={index}
+ data-test-subj={tab['data-test-subj']}
+ >
+ {tab.name}
+
+ )
+ );
return (
diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/cases_table/cases_table.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/cases_table/cases_table.test.tsx
index 9cc2e246d94da..0f59727007703 100644
--- a/x-pack/plugins/security_solution/public/overview/components/detection_response/cases_table/cases_table.test.tsx
+++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/cases_table/cases_table.test.tsx
@@ -25,6 +25,8 @@ jest.mock('../../../../common/lib/kibana/hooks', () => {
};
});
+jest.mock('../../../../common/components/guided_onboarding_tour/tour_step');
+
type UseCaseItemsReturn = ReturnType;
const defaultCaseItemsReturn: UseCaseItemsReturn = {
items: [],
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx
index b12fdb34e7f39..e48dbe5215f39 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/footer.test.tsx
@@ -96,6 +96,7 @@ jest.mock(
}
);
jest.mock('../../../../../detections/components/alerts_table/actions');
+jest.mock('../../../../../common/components/guided_onboarding_tour/tour_step');
const defaultProps = {
scopeId: TimelineId.test,
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx
index f64f3cf5fd73d..9d4264dd5b079 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx
@@ -42,7 +42,7 @@ jest.mock('../../../../../common/components/user_privileges', () => {
}),
};
});
-
+jest.mock('../../../../../common/components/guided_onboarding_tour/tour_step');
jest.mock('../../../../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../../../../common/lib/kibana');
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx
index 57ba0234f0c78..eb8aa54168461 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx
@@ -40,6 +40,7 @@ import type {
} from '@hello-pangea/dnd';
jest.mock('../../../../common/hooks/use_app_toasts');
+jest.mock('../../../../common/components/guided_onboarding_tour/tour_step');
jest.mock(
'../../../../detections/components/alerts_table/timeline_actions/use_add_to_case_actions'
);