[118684] Add ability for case owner selection in flyout (#120150)

* [118684] Add ability for case owner selection in flyout

* Update Flyout to handle permissions internally

* Fix failing tests

* Code review changes

* Fix tests

* Update test provider to use merge

* Add PR suggestions

* further PR suggestions

* Update language from case type to solution

* Fixes from code review

* Add key field for iteration and default to securitySolution

* final revisions

Co-authored-by: Kristof-Pierre Cummings <kristofpierre.cummings@elastic.co>
This commit is contained in:
Kristof C 2021-12-17 13:28:37 -06:00 committed by GitHub
parent 03f5c3b34c
commit b9c563c41c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 461 additions and 23 deletions

View file

@ -80,13 +80,19 @@ export const SUPPORTED_CONNECTORS = [
export const MAX_ALERTS_PER_SUB_CASE = 5000;
export const MAX_GENERATED_ALERTS_PER_SUB_CASE = 50;
/**
* This must be the same value that the security solution plugin uses to define the case kind when it registers the
* feature for the 7.13 migration only.
*
* This variable is being also used by test files and mocks.
*/
export const SECURITY_SOLUTION_OWNER = 'securitySolution';
export const OBSERVABILITY_OWNER = 'observability';
export const OWNER_INFO = {
[SECURITY_SOLUTION_OWNER]: {
label: 'Security',
iconType: 'logoSecurity',
},
[OBSERVABILITY_OWNER]: {
label: 'Observability',
iconType: 'logoObservability',
},
};
/**
* This flag governs enabling the case as a connector feature. It is disabled by default as the feature is not complete.

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import React from 'react';
import { merge } from 'lodash';
import React from 'react';
import { euiDarkVars } from '@kbn/ui-shared-deps-src/theme';
import { I18nProvider } from '@kbn/i18n-react';
import { ThemeProvider } from 'styled-components';
import { DEFAULT_FEATURES, SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { CasesFeatures } from '../../../common/ui/types';
import { CasesProvider } from '../../components/cases_context';
@ -20,6 +21,7 @@ interface Props {
children: React.ReactNode;
userCanCrud?: boolean;
features?: CasesFeatures;
owner?: string[];
}
window.scrollTo = jest.fn();
@ -28,8 +30,9 @@ const MockKibanaContextProvider = createKibanaContextProviderMock();
/** A utility for wrapping children in the providers required to run most tests */
const TestProvidersComponent: React.FC<Props> = ({
children,
features,
owner = [SECURITY_SOLUTION_OWNER],
userCanCrud = true,
features = {},
}) => {
/**
* The empty object at the beginning avoids the mutation
@ -40,9 +43,7 @@ const TestProvidersComponent: React.FC<Props> = ({
<I18nProvider>
<MockKibanaContextProvider>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<CasesProvider
value={{ owner: [SECURITY_SOLUTION_OWNER], userCanCrud, features: featuresOptions }}
>
<CasesProvider value={{ features: featuresOptions, owner, userCanCrud }}>
{children}
</CasesProvider>
</ThemeProvider>

View file

@ -56,6 +56,20 @@ export const DESCRIPTION_REQUIRED = i18n.translate(
}
);
export const SOLUTION_REQUIRED = i18n.translate(
'xpack.cases.createCase.solutionFieldRequiredError',
{
defaultMessage: 'A solution is required',
}
);
export const ARIA_KEYPAD_LEGEND = i18n.translate(
'xpack.cases.createCase.ariaKeypadSolutionSelection',
{
defaultMessage: 'Single solution select',
}
);
export const COMMENT_REQUIRED = i18n.translate('xpack.cases.caseView.commentFieldRequiredError', {
defaultMessage: 'A comment is required.',
});
@ -108,6 +122,10 @@ export const TAGS = i18n.translate('xpack.cases.caseView.tags', {
defaultMessage: 'Tags',
});
export const SOLUTION = i18n.translate('xpack.cases.caseView.solution', {
defaultMessage: 'Solution',
});
export const ACTIONS = i18n.translate('xpack.cases.allCases.actions', {
defaultMessage: 'Actions',
});

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { OBSERVABILITY_OWNER } from '../../../common/constants';
import { useKibana } from '../../common/lib/kibana';
import { useAvailableCasesOwners } from './use_available_owners';
jest.mock('../../common/lib/kibana');
const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>;
const hasAll = {
securitySolutionCases: {
crud_cases: true,
read_cases: true,
},
observabilityCases: {
crud_cases: true,
read_cases: true,
},
};
const hasSecurityAsCrudAndObservabilityAsRead = {
securitySolutionCases: {
crud_cases: true,
},
observabilityCases: {
read_cases: true,
},
};
const unrelatedFeatures = {
bogusCapability: {
crud_cases: true,
read_cases: true,
},
};
const mockKibana = (permissionType: unknown = hasAll) => {
useKibanaMock.mockReturnValue({
services: {
application: {
capabilities: permissionType,
},
},
} as unknown as ReturnType<typeof useKibana>);
};
describe('useAvailableCasesOwners correctly grabs user case permissions', () => {
it('returns all available owner types if user has access to all', () => {
mockKibana();
const { result } = renderHook(useAvailableCasesOwners);
expect(result.current).toEqual([SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER]);
});
it('returns no owner types if user has access to none', () => {
mockKibana({});
const { result } = renderHook(useAvailableCasesOwners);
expect(result.current).toEqual([]);
});
it('returns only the permission it should have with CRUD as default', () => {
mockKibana(hasSecurityAsCrudAndObservabilityAsRead);
const { result } = renderHook(useAvailableCasesOwners);
expect(result.current).toEqual([SECURITY_SOLUTION_OWNER]);
});
it('returns only the permission it should have with READ as default', () => {
mockKibana(hasSecurityAsCrudAndObservabilityAsRead);
const { result } = renderHook(() => useAvailableCasesOwners('read'));
expect(result.current).toEqual([OBSERVABILITY_OWNER]);
});
it('returns no owners when the capabilities does not contain valid entries', () => {
mockKibana(unrelatedFeatures);
const { result } = renderHook(useAvailableCasesOwners);
expect(result.current).toEqual([]);
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { useKibana } from '../../common/lib/kibana';
/**
*
* @param level : 'crud' | 'read' (default: 'crud')
*
* `securitySolution` owner uses cases capability feature id: 'securitySolutionCases'; //owner
* `observability` owner uses cases capability feature id: 'observabilityCases';
* both solutions use `crud_cases` and `read_cases` capability names
**/
export const useAvailableCasesOwners = (level: 'crud' | 'read' = 'crud'): string[] => {
const { capabilities } = useKibana().services.application;
const capabilityName = `${level}_cases`;
return Object.entries(capabilities).reduce(
(availableOwners: string[], [featureId, capability]) => {
if (featureId.endsWith('Cases') && !!capability[capabilityName]) {
availableOwners.push(featureId.replace('Cases', ''));
}
return availableOwners;
},
[]
);
};

View file

@ -23,6 +23,9 @@ jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/configure/use_configure');
jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment');
jest.mock('../app/use_available_owners', () => ({
useAvailableCasesOwners: () => ['securitySolution', 'observability'],
}));
const useGetTagsMock = useGetTags as jest.Mock;
const useConnectorsMock = useConnectors as jest.Mock;
@ -90,7 +93,7 @@ describe('CreateCaseForm', () => {
expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeFalsy();
});
it('it renders all form fields', async () => {
it('it renders all form fields except case selection', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<CreateCaseForm {...casesFormProps} />
@ -102,6 +105,22 @@ describe('CreateCaseForm', () => {
expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeFalsy();
});
it('renders all form fields including case selection if has permissions and no owner', async () => {
const wrapper = mount(
<MockHookWrapperComponent testProviderProps={{ owner: [] }}>
<CreateCaseForm {...casesFormProps} />
</MockHookWrapperComponent>
);
expect(wrapper.find(`[data-test-subj="caseTitle"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseTags"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeTruthy();
});
it('hides the sync alerts toggle', () => {

View file

@ -31,6 +31,9 @@ import { UsePostComment } from '../../containers/use_post_comment';
import { SubmitCaseButton } from './submit_button';
import { FormContext } from './form_context';
import { useCasesFeatures } from '../cases_context/use_cases_features';
import { CreateCaseOwnerSelector } from './owner_selector';
import { useCasesContext } from '../cases_context/use_cases_context';
import { useAvailableCasesOwners } from '../app/use_available_owners';
interface ContainerProps {
big?: boolean;
@ -70,6 +73,10 @@ export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.m
const { isSubmitting } = useFormContext();
const { isSyncAlertsEnabled } = useCasesFeatures();
const { owner } = useCasesContext();
const availableOwners = useAvailableCasesOwners();
const canShowCaseSolutionSelection = !owner.length && availableOwners.length;
const firstStep = useMemo(
() => ({
title: i18n.STEP_ONE_TITLE,
@ -79,13 +86,21 @@ export const CreateCaseFormFields: React.FC<CreateCaseFormFieldsProps> = React.m
<Container>
<Tags isLoading={isSubmitting} />
</Container>
{canShowCaseSolutionSelection && (
<Container big>
<CreateCaseOwnerSelector
availableOwners={availableOwners}
isLoading={isSubmitting}
/>
</Container>
)}
<Container big>
<Description isLoading={isSubmitting} />
</Container>
</>
),
}),
[isSubmitting]
[isSubmitting, canShowCaseSolutionSelection, availableOwners]
);
const secondStep = useMemo(
@ -156,12 +171,7 @@ export const CreateCaseForm: React.FC<CreateCaseFormProps> = React.memo(
timelineIntegration,
}) => (
<CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}>
<FormContext
afterCaseCreated={afterCaseCreated}
caseType={caseType}
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
onSuccess={onSuccess}
>
<FormContext afterCaseCreated={afterCaseCreated} caseType={caseType} onSuccess={onSuccess}>
<CreateCaseFormFields
connectors={empty}
isLoadingConnectors={false}

View file

@ -27,13 +27,13 @@ const initialCaseValue: FormProps = {
connectorId: 'none',
fields: null,
syncAlerts: true,
selectedOwner: null,
};
interface Props {
afterCaseCreated?: (theCase: Case, postComment: UsePostComment['postComment']) => Promise<void>;
caseType?: CaseType;
children?: JSX.Element | JSX.Element[];
hideConnectorServiceNowSir?: boolean;
onSuccess?: (theCase: Case) => Promise<void>;
}
@ -41,7 +41,6 @@ export const FormContext: React.FC<Props> = ({
afterCaseCreated,
caseType = CaseType.individual,
children,
hideConnectorServiceNowSir,
onSuccess,
}) => {
const { connectors, loading: isLoadingConnectors } = useConnectors();
@ -62,6 +61,7 @@ export const FormContext: React.FC<Props> = ({
isValid
) => {
if (isValid) {
const { selectedOwner, ...userFormData } = dataWithoutConnectorId;
const caseConnector = getConnectorById(dataConnectorId, connectors);
const connectorToUpdate = caseConnector
@ -69,11 +69,11 @@ export const FormContext: React.FC<Props> = ({
: getNoneConnector();
const updatedCase = await postCase({
...dataWithoutConnectorId,
...userFormData,
type: caseType,
connector: connectorToUpdate,
settings: { syncAlerts },
owner: owner[0],
owner: selectedOwner ?? owner[0],
});
if (afterCaseCreated && updatedCase) {

View file

@ -0,0 +1,135 @@
/*
* 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 { mount } from 'enzyme';
import { act, waitFor } from '@testing-library/react';
import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { OBSERVABILITY_OWNER } from '../../../common/constants';
import { useForm, Form, FormHook } from '../../common/shared_imports';
import { CreateCaseOwnerSelector } from './owner_selector';
import { schema, FormProps } from './schema';
describe('Case Owner Selection', () => {
let globalForm: FormHook;
const MockHookWrapperComponent: React.FC = ({ children }) => {
const { form } = useForm<FormProps>({
defaultValue: { selectedOwner: '' },
schema: {
selectedOwner: schema.selectedOwner,
},
});
globalForm = form;
return <Form form={form}>{children}</Form>;
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders', () => {
const wrapper = mount(
<MockHookWrapperComponent>
<CreateCaseOwnerSelector availableOwners={[SECURITY_SOLUTION_OWNER]} isLoading={false} />
</MockHookWrapperComponent>
);
expect(wrapper.find(`[data-test-subj="caseOwnerSelector"]`).exists()).toBeTruthy();
});
it.each([
[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER],
[SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER],
])('disables %s button if user only has %j', (disabledButton, permission) => {
const wrapper = mount(
<MockHookWrapperComponent>
<CreateCaseOwnerSelector availableOwners={[permission]} isLoading={false} />
</MockHookWrapperComponent>
);
expect(
wrapper.find(`[data-test-subj="${disabledButton}RadioButton"] input`).first().props().disabled
).toBeTruthy();
expect(
wrapper.find(`[data-test-subj="${permission}RadioButton"] input`).first().props().disabled
).toBeFalsy();
expect(
wrapper.find(`[data-test-subj="${permission}RadioButton"] input`).first().props().checked
).toBeTruthy();
});
it('defaults to security Solution', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<CreateCaseOwnerSelector
availableOwners={[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]}
isLoading={false}
/>
</MockHookWrapperComponent>
);
expect(
wrapper.find(`[data-test-subj="observabilityRadioButton"] input`).first().props().checked
).toBeFalsy();
expect(
wrapper.find(`[data-test-subj="securitySolutionRadioButton"] input`).first().props().checked
).toBeTruthy();
});
it('it changes the selection', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<CreateCaseOwnerSelector
availableOwners={[OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER]}
isLoading={false}
/>
</MockHookWrapperComponent>
);
await act(async () => {
wrapper
.find(`[data-test-subj="observabilityRadioButton"] input`)
.first()
.simulate('change', OBSERVABILITY_OWNER);
});
await waitFor(() => {
wrapper.update();
expect(
wrapper.find(`[data-test-subj="observabilityRadioButton"] input`).first().props().checked
).toBeTruthy();
expect(
wrapper.find(`[data-test-subj="securitySolutionRadioButton"] input`).first().props().checked
).toBeFalsy();
});
expect(globalForm.getFormData()).toEqual({ selectedOwner: OBSERVABILITY_OWNER });
await act(async () => {
wrapper
.find(`[data-test-subj="securitySolutionRadioButton"] input`)
.first()
.simulate('change', SECURITY_SOLUTION_OWNER);
});
await waitFor(() => {
wrapper.update();
expect(
wrapper.find(`[data-test-subj="securitySolutionRadioButton"] input`).first().props().checked
).toBeTruthy();
expect(
wrapper.find(`[data-test-subj="observabilityRadioButton"] input`).first().props().checked
).toBeFalsy();
});
expect(globalForm.getFormData()).toEqual({ selectedOwner: SECURITY_SOLUTION_OWNER });
});
});

View file

@ -0,0 +1,117 @@
/*
* 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, { memo, useCallback, useEffect } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIcon,
EuiKeyPadMenu,
EuiKeyPadMenuItem,
useGeneratedHtmlId,
} from '@elastic/eui';
import { euiStyled } from '../../../../../../src/plugins/kibana_react/common';
import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { OBSERVABILITY_OWNER, OWNER_INFO } from '../../../common/constants';
import { FieldHook, getFieldValidityAndErrorMessage, UseField } from '../../common/shared_imports';
import * as i18n from './translations';
interface OwnerSelectorProps {
field: FieldHook<string>;
isLoading: boolean;
availableOwners: string[];
}
interface Props {
availableOwners: string[];
isLoading: boolean;
}
const DEFAULT_SELECTABLE_OWNERS = [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER] as const;
const FIELD_NAME = 'selectedOwner';
const FullWidthKeyPadMenu = euiStyled(EuiKeyPadMenu)`
width: 100%;
`;
const FullWidthKeyPadItem = euiStyled(EuiKeyPadMenuItem)`
width: 100%;
`;
const OwnerSelector = ({
availableOwners,
field,
isLoading = false,
}: OwnerSelectorProps): JSX.Element => {
const { errorMessage, isInvalid } = getFieldValidityAndErrorMessage(field);
const radioGroupName = useGeneratedHtmlId({ prefix: 'caseOwnerRadioGroup' });
const onChange = useCallback((val: string) => field.setValue(val), [field]);
useEffect(() => {
if (!field.value) {
onChange(
availableOwners.includes(SECURITY_SOLUTION_OWNER)
? SECURITY_SOLUTION_OWNER
: availableOwners[0]
);
}
}, [availableOwners, field.value, onChange]);
return (
<EuiFormRow
data-test-subj="caseOwnerSelector"
fullWidth
isInvalid={isInvalid}
error={errorMessage}
helpText={field.helpText}
label={field.label}
labelAppend={field.labelAppend}
>
<FullWidthKeyPadMenu checkable={{ ariaLegend: i18n.ARIA_KEYPAD_LEGEND }}>
<EuiFlexGroup>
{DEFAULT_SELECTABLE_OWNERS.map((owner) => (
<EuiFlexItem key={owner}>
<FullWidthKeyPadItem
data-test-subj={`${owner}RadioButton`}
onChange={onChange}
checkable="single"
name={radioGroupName}
id={owner}
label={OWNER_INFO[owner].label}
isSelected={field.value === owner}
isDisabled={isLoading || !availableOwners.includes(owner)}
>
<EuiIcon type={OWNER_INFO[owner].iconType} size="xl" />
</FullWidthKeyPadItem>
</EuiFlexItem>
))}
</EuiFlexGroup>
</FullWidthKeyPadMenu>
</EuiFormRow>
);
};
const CaseOwnerSelector: React.FC<Props> = ({ availableOwners, isLoading }) => {
return (
<UseField
path={FIELD_NAME}
component={OwnerSelector}
componentProps={{ availableOwners, isLoading }}
/>
);
};
CaseOwnerSelector.displayName = 'CaseOwnerSelectionComponent';
export const CreateCaseOwnerSelector = memo(CaseOwnerSelector);

View file

@ -36,6 +36,7 @@ export type FormProps = Omit<CasePostRequest, 'connector' | 'settings' | 'owner'
connectorId: string;
fields: ConnectorTypeFields['fields'];
syncAlerts: boolean;
selectedOwner?: string | null;
};
export const schema: FormSchema<FormProps> = {
@ -62,6 +63,15 @@ export const schema: FormSchema<FormProps> = {
},
],
},
selectedOwner: {
label: i18n.SOLUTION,
type: FIELD_TYPES.RADIO_GROUP,
validations: [
{
validator: emptyField(i18n.SOLUTION_REQUIRED),
},
],
},
tags: schemaTags,
connectorId: {
type: FIELD_TYPES.SUPER_SELECT,