Skip to content

Commit

Permalink
[Security solution] Guided onboarding, alerts & cases design updates (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
stephmilovic authored Nov 9, 2022
1 parent 47f38bc commit b721fdc
Show file tree
Hide file tree
Showing 28 changed files with 856 additions and 217 deletions.
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@
/x-pack/plugins/security_solution/cypress/tasks/network @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/cypress/upgrade_e2e/threat_hunting/cases @elastic/security-threat-hunting-explore

/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/common/components/charts @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/common/components/header_page @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/common/components/header_section @elastic/security-threat-hunting-explore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import styled, { createGlobalStyle } from 'styled-components';
import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui';

import { QueryClientProvider } from '@tanstack/react-query';
import type { CasePostRequest } from '../../../../common/api';
import * as i18n from '../translations';
import type { Case } from '../../../../common/ui/types';
import { CreateCaseForm } from '../form';
Expand All @@ -26,6 +27,7 @@ export interface CreateCaseFlyoutProps {
onSuccess?: (theCase: Case) => Promise<void>;
attachments?: CaseAttachmentsWithoutOwner;
headerContent?: React.ReactNode;
initialValue?: Pick<CasePostRequest, 'title' | 'description'>;
}

const StyledFlyout = styled(EuiFlyout)`
Expand Down Expand Up @@ -72,7 +74,7 @@ const FormWrapper = styled.div`
`;

export const CreateCaseFlyout = React.memo<CreateCaseFlyoutProps>(
({ afterCaseCreated, onClose, onSuccess, attachments, headerContent }) => {
({ afterCaseCreated, attachments, headerContent, initialValue, onClose, onSuccess }) => {
const handleCancel = onClose || function () {};
const handleOnSuccess = onSuccess || async function () {};

Expand All @@ -81,6 +83,7 @@ export const CreateCaseFlyout = React.memo<CreateCaseFlyoutProps>(
<GlobalStyle />
<StyledFlyout
onClose={onClose}
tour-step="create-case-flyout"
data-test-subj="create-case-flyout"
// maskProps is needed in order to apply the z-index to the parent overlay element, not to the flyout only
maskProps={{ className: maskOverlayClassName }}
Expand All @@ -99,6 +102,7 @@ export const CreateCaseFlyout = React.memo<CreateCaseFlyoutProps>(
onCancel={handleCancel}
onSuccess={handleOnSuccess}
withSteps={false}
initialValue={initialValue}
/>
</FormWrapper>
</StyledEuiFlyoutBody>
Expand Down
32 changes: 31 additions & 1 deletion x-pack/plugins/cases/public/components/create/form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import React from 'react';
import { mount } from 'enzyme';
import { act, render } from '@testing-library/react';
import { act, render, within } from '@testing-library/react';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';

import { NONE_CONNECTOR_ID } from '../../../common/api';
Expand Down Expand Up @@ -182,4 +182,34 @@ describe('CreateCaseForm', () => {

expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument();
});

it('should not prefill the form when no initialValue provided', () => {
const { getByTestId } = render(
<MockHookWrapperComponent>
<CreateCaseForm {...casesFormProps} />
</MockHookWrapperComponent>
);

const titleInput = within(getByTestId('caseTitle')).getByTestId('input');
const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox');
expect(titleInput).toHaveValue('');
expect(descriptionInput).toHaveValue('');
});

it('should prefill the form when provided with initialValue', () => {
const { getByTestId } = render(
<MockHookWrapperComponent>
<CreateCaseForm
{...casesFormProps}
initialValue={{ title: 'title', description: 'description' }}
/>
</MockHookWrapperComponent>
);

const titleInput = within(getByTestId('caseTitle')).getByTestId('input');
const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox');

expect(titleInput).toHaveValue('title');
expect(descriptionInput).toHaveValue('description');
});
});
6 changes: 5 additions & 1 deletion x-pack/plugins/cases/public/components/create/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { Tags } from './tags';
import { Connector } from './connector';
import * as i18n from './translations';
import { SyncAlertsToggle } from './sync_alerts_toggle';
import type { ActionConnector } from '../../../common/api';
import type { ActionConnector, CasePostRequest } from '../../../common/api';
import type { Case } from '../../containers/types';
import type { CasesTimelineIntegration } from '../timeline_context';
import { CasesTimelineIntegrationProvider } from '../timeline_context';
Expand Down Expand Up @@ -70,6 +70,7 @@ export interface CreateCaseFormProps extends Pick<Partial<CreateCaseFormFieldsPr
) => Promise<void>;
timelineIntegration?: CasesTimelineIntegration;
attachments?: CaseAttachmentsWithoutOwner;
initialValue?: Pick<CasePostRequest, 'title' | 'description'>;
}

const empty: ActionConnector[] = [];
Expand All @@ -79,6 +80,7 @@ export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.m
const { isSyncAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures();

const { owner } = useCasesContext();

const availableOwners = useAvailableCasesOwners();
const canShowCaseSolutionSelection = !owner.length && availableOwners.length;

Expand Down Expand Up @@ -181,12 +183,14 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo(
onSuccess,
timelineIntegration,
attachments,
initialValue,
}) => (
<CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}>
<FormContext
afterCaseCreated={afterCaseCreated}
onSuccess={onSuccess}
attachments={attachments}
initialValue={initialValue}
>
<CreateCaseFormFields
connectors={empty}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { usePostCase } from '../../containers/use_post_case';
import { usePostPushToService } from '../../containers/use_post_push_to_service';

import type { Case } from '../../containers/types';
import type { CasePostRequest } from '../../../common/api';
import { CaseSeverity, NONE_CONNECTOR_ID } from '../../../common/api';
import type { UseCreateAttachments } from '../../containers/use_create_attachments';
import { useCreateAttachments } from '../../containers/use_create_attachments';
Expand Down Expand Up @@ -44,13 +45,15 @@ interface Props {
children?: JSX.Element | JSX.Element[];
onSuccess?: (theCase: Case) => Promise<void>;
attachments?: CaseAttachmentsWithoutOwner;
initialValue?: Pick<CasePostRequest, 'title' | 'description'>;
}

export const FormContext: React.FC<Props> = ({
afterCaseCreated,
children,
onSuccess,
attachments,
initialValue,
}) => {
const { data: connectors = [], isLoading: isLoadingConnectors } = useGetConnectors();
const { owner, appId } = useCasesContext();
Expand Down Expand Up @@ -128,7 +131,7 @@ export const FormContext: React.FC<Props> = ({
);

const { form } = useForm<FormProps>({
defaultValue: initialCaseValue,
defaultValue: { ...initialCaseValue, ...initialValue },
options: { stripEmptyFields: false },
schema,
onSubmit: submitCase,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const SubmitCaseButtonComponent: React.FC = () => {

return (
<EuiButton
tour-step="create-case-submit"
data-test-subj="create-case-submit"
fill
iconType="plusInCircle"
Expand Down
93 changes: 93 additions & 0 deletions x-pack/plugins/security_solution/public/cases/pages/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* 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 { Cases } from '.';
import { Router } from 'react-router-dom';
import { render } from '@testing-library/react';
import { TestProviders } from '../../common/mock';
import { useTourContext } from '../../common/components/guided_onboarding_tour';
import {
AlertsCasesTourSteps,
SecurityStepId,
} from '../../common/components/guided_onboarding_tour/tour_config';

jest.mock('../../common/components/guided_onboarding_tour');

type Action = 'PUSH' | 'POP' | 'REPLACE';
const pop: Action = 'POP';
const location = {
pathname: '/network',
search: '',
state: '',
hash: '',
};
const mockHistory = {
length: 2,
location,
action: pop,
push: jest.fn(),
replace: jest.fn(),
go: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
block: jest.fn(),
createHref: jest.fn(),
listen: jest.fn(),
};

describe('cases page in security', () => {
const endTourStep = jest.fn();
beforeEach(() => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: AlertsCasesTourSteps.viewCase,
incrementStep: () => null,
endTourStep,
isTourShown: () => true,
});
jest.clearAllMocks();
});
it('calls endTour on cases details page when SecurityStepId.alertsCases tour is active and step is AlertsCasesTourSteps.viewCase', () => {
render(
<Router history={mockHistory}>
<Cases />
</Router>,
{ wrapper: TestProviders }
);
expect(endTourStep).toHaveBeenCalledWith(SecurityStepId.alertsCases);
});
it('does not call endTour on cases details page when SecurityStepId.alertsCases tour is not active', () => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: AlertsCasesTourSteps.viewCase,
incrementStep: () => null,
endTourStep,
isTourShown: () => false,
});
render(
<Router history={mockHistory}>
<Cases />
</Router>,
{ wrapper: TestProviders }
);
expect(endTourStep).not.toHaveBeenCalled();
});
it('does not call endTour on cases details page when SecurityStepId.alertsCases tour is active and step is not AlertsCasesTourSteps.viewCase', () => {
(useTourContext as jest.Mock).mockReturnValue({
activeStep: AlertsCasesTourSteps.expandEvent,
incrementStep: () => null,
endTourStep,
isTourShown: () => true,
});
render(
<Router history={mockHistory}>
<Cases />
</Router>,
{ wrapper: TestProviders }
);
expect(endTourStep).not.toHaveBeenCalled();
});
});
17 changes: 16 additions & 1 deletion x-pack/plugins/security_solution/public/cases/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@
* 2.0.
*/

import React, { useCallback, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useDispatch } from 'react-redux';
import type { CaseViewRefreshPropInterface } from '@kbn/cases-plugin/common';
import { useTourContext } from '../../common/components/guided_onboarding_tour';
import {
AlertsCasesTourSteps,
SecurityStepId,
} from '../../common/components/guided_onboarding_tour/tour_config';
import { TimelineId } from '../../../common/types/timeline';

import { getRuleDetailsUrl, useFormatUrl } from '../../common/components/link_to';
Expand Down Expand Up @@ -91,6 +96,16 @@ const CaseContainerComponent: React.FC = () => {
}, [dispatch]);

const refreshRef = useRef<CaseViewRefreshPropInterface>(null);
const { activeStep, endTourStep, isTourShown } = useTourContext();

const isTourActive = useMemo(
() => activeStep === AlertsCasesTourSteps.viewCase && isTourShown(SecurityStepId.alertsCases),
[activeStep, isTourShown]
);

useEffect(() => {
if (isTourActive) endTourStep(SecurityStepId.alertsCases);
}, [endTourStep, isTourActive]);

return (
<SecuritySolutionPageWrapper noPadding>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ import type { SearchHit } from '../../../../common/search_strategy';
import { getMitreComponentParts } from '../../../detections/mitre/get_mitre_threat_component';
import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step';
import { isDetectionsAlertsTable } from '../top_n/helpers';
import { getTourAnchor, SecurityStepId } from '../guided_onboarding_tour/tour_config';
import {
AlertsCasesTourSteps,
getTourAnchor,
SecurityStepId,
} from '../guided_onboarding_tour/tour_config';
import type { AlertRawEventData } from './osquery_tab';
import { useOsqueryTab } from './osquery_tab';
import { EventFieldsBrowser } from './event_fields_browser';
Expand Down Expand Up @@ -448,8 +452,8 @@ const EventDetailsComponent: React.FC<Props> = ({
return (
<GuidedOnboardingTourStep
isTourAnchor={isTourAnchor}
step={3}
stepId={SecurityStepId.alertsCases}
step={AlertsCasesTourSteps.reviewAlertDetailsFlyout}
tourId={SecurityStepId.alertsCases}
>
<StyledEuiTabbedContent
{...tourAnchor}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@ interface Props {
renderContent: () => ReactNode;
extraAction?: EuiAccordionProps['extraAction'];
onToggle?: EuiAccordionProps['onToggle'];
forceState?: EuiAccordionProps['forceState'];
}

/**
* A special accordion that is used in the Insights section on the alert flyout.
* It wraps logic and custom styling around the loading, error and success states of an insight section.
*/
export const InsightAccordion = React.memo<Props>(
({ prefix, state, text, renderContent, onToggle = noop, extraAction }) => {
({ prefix, state, text, renderContent, onToggle = noop, extraAction, forceState }) => {
const accordionId = useGeneratedHtmlId({ prefix });

switch (state) {
Expand Down Expand Up @@ -62,11 +63,14 @@ export const InsightAccordion = React.memo<Props>(
// The accordion can display the content now
return (
<StyledAccordion
tour-step={`${prefix}-accordion`}
data-test-subj={`${prefix}-accordion`}
id={accordionId}
buttonContent={text}
onToggle={onToggle}
paddingSize="l"
extraAction={extraAction}
forceState={forceState}
>
{renderContent()}
</StyledAccordion>
Expand Down
Loading

0 comments on commit b721fdc

Please sign in to comment.