[RAC] [Cases] All cases table column design updates (#103544)

This commit is contained in:
Steph Milovic 2021-06-29 13:53:56 -06:00 committed by GitHub
parent a5660fe82c
commit c24318ae40
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 195 additions and 235 deletions

View file

@ -5,87 +5,24 @@
* 2.0.
*/
import { Dispatch } from 'react';
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
import { CaseStatuses } from '../../../common';
import { Case, SubCase } from '../../containers/types';
import { UpdateCase } from '../../containers/use_get_cases';
import { statuses } from '../status';
import { Case } from '../../containers/types';
import * as i18n from './translations';
import { isIndividual } from './helpers';
interface GetActions {
dispatchUpdate: Dispatch<Omit<UpdateCase, 'refetchCasesStatus'>>;
deleteCaseOnClick: (deleteCase: Case) => void;
}
export const getActions = ({
dispatchUpdate,
deleteCaseOnClick,
}: GetActions): Array<DefaultItemIconButtonAction<Case>> => {
const openCaseAction = {
available: (item: Case | SubCase) => item.status !== CaseStatuses.open,
enabled: (item: Case | SubCase) => isIndividual(item),
description: statuses[CaseStatuses.open].actions.single.title,
icon: statuses[CaseStatuses.open].icon,
name: statuses[CaseStatuses.open].actions.single.title,
onClick: (theCase: Case) =>
dispatchUpdate({
updateKey: 'status',
updateValue: CaseStatuses.open,
caseId: theCase.id,
version: theCase.version,
}),
type: 'icon' as const,
'data-test-subj': 'action-open',
};
const makeInProgressAction = {
available: (item: Case) => item.status !== CaseStatuses['in-progress'],
enabled: (item: Case | SubCase) => isIndividual(item),
description: statuses[CaseStatuses['in-progress']].actions.single.title,
icon: statuses[CaseStatuses['in-progress']].icon,
name: statuses[CaseStatuses['in-progress']].actions.single.title,
onClick: (theCase: Case) =>
dispatchUpdate({
updateKey: 'status',
updateValue: CaseStatuses['in-progress'],
caseId: theCase.id,
version: theCase.version,
}),
type: 'icon' as const,
'data-test-subj': 'action-in-progress',
};
const closeCaseAction = {
available: (item: Case | SubCase) => item.status !== CaseStatuses.closed,
enabled: (item: Case | SubCase) => isIndividual(item),
description: statuses[CaseStatuses.closed].actions.single.title,
icon: statuses[CaseStatuses.closed].icon,
name: statuses[CaseStatuses.closed].actions.single.title,
onClick: (theCase: Case) =>
dispatchUpdate({
updateKey: 'status',
updateValue: CaseStatuses.closed,
caseId: theCase.id,
version: theCase.version,
}),
type: 'icon' as const,
'data-test-subj': 'action-close',
};
return [
openCaseAction,
makeInProgressAction,
closeCaseAction,
{
description: i18n.DELETE_CASE(),
icon: 'trash',
name: i18n.DELETE_CASE(),
onClick: deleteCaseOnClick,
type: 'icon',
'data-test-subj': 'action-delete',
},
];
};
}: GetActions): Array<DefaultItemIconButtonAction<Case>> => [
{
description: i18n.DELETE_CASE(),
icon: 'trash',
name: i18n.DELETE_CASE(),
onClick: deleteCaseOnClick,
type: 'icon',
'data-test-subj': 'action-delete',
},
];

View file

@ -34,6 +34,24 @@ const alertDataMock = {
alertId: 'alert-id',
owner: SECURITY_SOLUTION_OWNER,
};
jest.mock('../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../common/lib/kibana');
return {
...originalModule,
useKibana: () => ({
services: {
triggersActionsUi: {
actionTypeRegistry: {
get: jest.fn().mockReturnValue({
actionTypeTitle: '.jira',
iconClass: 'logoSecurity',
}),
},
},
},
}),
};
});
describe('AllCasesGeneric ', () => {
beforeEach(() => {

View file

@ -199,6 +199,7 @@ export const AllCasesGeneric = React.memo<AllCasesGenericProps>(
isLoadingCases: loading,
refreshCases,
showActions,
userCanCrud,
});
const itemIdToExpandedRowMap = useMemo(

View file

@ -13,6 +13,25 @@ import { ExternalServiceColumn } from './columns';
import { useGetCasesMockState } from '../../containers/mock';
jest.mock('../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../common/lib/kibana');
return {
...originalModule,
useKibana: () => ({
services: {
triggersActionsUi: {
actionTypeRegistry: {
get: jest.fn().mockReturnValue({
actionTypeTitle: '.jira',
iconClass: 'logoSecurity',
}),
},
},
},
}),
};
});
describe('ExternalServiceColumn ', () => {
it('Not pushed render', () => {
const wrapper = mount(

View file

@ -16,6 +16,7 @@ import {
EuiTableFieldDataColumnType,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
} from '@elastic/eui';
import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services';
import styled from 'styled-components';
@ -25,13 +26,14 @@ import { getEmptyTagValue } from '../empty_value';
import { FormattedRelativePreferenceDate } from '../formatted_date';
import { CaseDetailsHrefSchema, CaseDetailsLink, CasesNavigation } from '../links';
import * as i18n from './translations';
import { Status } from '../status';
import { getSubCasesStatusCountsBadges, isSubCase } from './helpers';
import { ALERTS } from '../../common/translations';
import { getActions } from './actions';
import { UpdateCase } from '../../containers/use_get_cases';
import { useDeleteCases } from '../../containers/use_delete_cases';
import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
import { useKibana } from '../../common/lib/kibana';
import { StatusContextMenu } from '../case_action_bar/status_context_menu';
export type CasesColumns =
| EuiTableActionsColumnType<Case>
@ -62,6 +64,7 @@ export interface GetCasesColumn {
isLoadingCases: string[];
refreshCases?: (a?: boolean) => void;
showActions: boolean;
userCanCrud: boolean;
}
export const useCasesColumns = ({
caseDetailsNavigation,
@ -72,6 +75,7 @@ export const useCasesColumns = ({
isLoadingCases,
refreshCases,
showActions,
userCanCrud,
}: GetCasesColumn): CasesColumns[] => {
// Delete case
const {
@ -113,9 +117,8 @@ export const useCasesColumns = ({
() =>
getActions({
deleteCaseOnClick: toggleDeleteModal,
dispatchUpdate: handleDispatchUpdate,
}),
[toggleDeleteModal, handleDispatchUpdate]
[toggleDeleteModal]
);
useEffect(() => {
@ -267,18 +270,6 @@ export const useCasesColumns = ({
return getEmptyTagValue();
},
},
{
name: i18n.INCIDENT_MANAGEMENT_SYSTEM,
render: (theCase: Case) => {
if (theCase.externalService != null) {
return renderStringField(
`${theCase.externalService.connectorName}`,
`case-table-column-connector`
);
}
return getEmptyTagValue();
},
},
{
name: i18n.STATUS,
render: (theCase: Case) => {
@ -286,7 +277,20 @@ export const useCasesColumns = ({
if (theCase.status == null || theCase.type === CaseType.collection) {
return getEmptyTagValue();
}
return <Status type={theCase.status} />;
return (
<StatusContextMenu
currentStatus={theCase.status}
disabled={!userCanCrud || isLoadingCases.length > 0}
onStatusChanged={(status) =>
handleDispatchUpdate({
updateKey: 'status',
updateValue: status,
caseId: theCase.id,
version: theCase.version,
})
}
/>
);
}
const badges = getSubCasesStatusCountsBadges(theCase.subCases);
@ -322,36 +326,48 @@ interface Props {
theCase: Case;
}
export const ExternalServiceColumn: React.FC<Props> = ({ theCase }) => {
const handleRenderDataToPush = useCallback(() => {
const lastCaseUpdate = theCase.updatedAt != null ? new Date(theCase.updatedAt) : null;
const lastCasePush =
theCase.externalService?.pushedAt != null
? new Date(theCase.externalService?.pushedAt)
: null;
const hasDataToPush =
lastCasePush === null ||
(lastCasePush != null &&
lastCaseUpdate != null &&
lastCasePush.getTime() < lastCaseUpdate?.getTime());
return (
<p>
<EuiLink
data-test-subj={`case-table-column-external`}
href={theCase.externalService?.externalUrl}
target="_blank"
aria-label={i18n.SERVICENOW_LINK_ARIA}
>
{theCase.externalService?.externalTitle}
</EuiLink>
{hasDataToPush
? renderStringField(i18n.REQUIRES_UPDATE, `case-table-column-external-requiresUpdate`)
: renderStringField(i18n.UP_TO_DATE, `case-table-column-external-upToDate`)}
</p>
);
}, [theCase]);
if (theCase.externalService !== null) {
return handleRenderDataToPush();
const IconWrapper = styled.span`
svg {
height: 20px !important;
position: relative;
top: 3px;
width: 20px !important;
}
return renderStringField(i18n.NOT_PUSHED, `case-table-column-external-notPushed`);
`;
export const ExternalServiceColumn: React.FC<Props> = ({ theCase }) => {
const { triggersActionsUi } = useKibana().services;
if (theCase.externalService == null) {
return renderStringField(i18n.NOT_PUSHED, `case-table-column-external-notPushed`);
}
const lastCaseUpdate = theCase.updatedAt != null ? new Date(theCase.updatedAt) : null;
const lastCasePush =
theCase.externalService?.pushedAt != null ? new Date(theCase.externalService?.pushedAt) : null;
const hasDataToPush =
lastCasePush === null ||
(lastCaseUpdate != null && lastCasePush.getTime() < lastCaseUpdate?.getTime());
return (
<p>
<IconWrapper>
<EuiIcon
size="original"
title={theCase.externalService?.connectorName}
type={triggersActionsUi.actionTypeRegistry.get(theCase.connector.type)?.iconClass ?? ''}
/>
</IconWrapper>
<EuiLink
data-test-subj={`case-table-column-external`}
title={theCase.externalService?.connectorName}
href={theCase.externalService?.externalUrl}
target="_blank"
aria-label={i18n.PUSH_LINK_ARIA(theCase.externalService?.connectorName)}
>
{theCase.externalService?.externalTitle}
</EuiLink>
{hasDataToPush
? renderStringField(i18n.REQUIRES_UPDATE, `case-table-column-external-requiresUpdate`)
: renderStringField(i18n.UP_TO_DATE, `case-table-column-external-upToDate`)}
</p>
);
};

View file

@ -36,7 +36,24 @@ const useGetCasesStatusMock = useGetCasesStatus as jest.Mock;
const useUpdateCasesMock = useUpdateCases as jest.Mock;
const useGetActionLicenseMock = useGetActionLicense as jest.Mock;
jest.mock('../../common/lib/kibana');
jest.mock('../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../common/lib/kibana');
return {
...originalModule,
useKibana: () => ({
services: {
triggersActionsUi: {
actionTypeRegistry: {
get: jest.fn().mockReturnValue({
actionTypeTitle: '.jira',
iconClass: 'logoSecurity',
}),
},
},
},
}),
};
});
describe('AllCasesGeneric', () => {
const defaultAllCasesProps: AllCasesProps = {
@ -119,6 +136,7 @@ describe('AllCasesGeneric', () => {
handleIsLoading: jest.fn(),
isLoadingCases: [],
showActions: true,
userCanCrud: true,
};
beforeEach(() => {
@ -274,7 +292,7 @@ describe('AllCasesGeneric', () => {
});
});
it('should render correct actions for case (with type individual and filter status open)', async () => {
it('should render delete actions for case', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open },
@ -284,18 +302,12 @@ describe('AllCasesGeneric', () => {
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="euiCollapsedItemActionsButton"]').first().simulate('click');
await waitFor(() => {
expect(wrapper.find('[data-test-subj="action-open"]').exists()).toBeFalsy();
expect(
wrapper.find('[data-test-subj="action-in-progress"]').first().props().disabled
).toBeFalsy();
expect(wrapper.find('[data-test-subj="action-close"]').first().props().disabled).toBeFalsy();
expect(wrapper.find('[data-test-subj="action-delete"]').first().props().disabled).toBeFalsy();
});
});
it('should enable correct actions for sub cases', async () => {
it.skip('should enable correct actions for sub cases', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
data: {
@ -327,25 +339,9 @@ describe('AllCasesGeneric', () => {
</TestProviders>
);
wrapper
.find(
'[data-test-subj="sub-cases-table-my-case-with-subcases"] [data-test-subj="euiCollapsedItemActionsButton"]'
)
.last()
.simulate('click');
await waitFor(() => {
expect(wrapper.find('[data-test-subj="action-open"]').first().props().disabled).toEqual(true);
expect(
wrapper.find('[data-test-subj="action-in-progress"]').first().props().disabled
).toEqual(true);
expect(wrapper.find('[data-test-subj="action-close"]').first().props().disabled).toEqual(
true
);
expect(wrapper.find('[data-test-subj="action-delete"]').first().props().disabled).toEqual(
false
);
});
expect(wrapper.find('[data-test-subj="action-delete"]').first().props().disabled).toEqual(
false
);
});
it('should not render case link when caseDetailsNavigation is not passed or actions on showActions=false', async () => {
@ -362,6 +358,7 @@ describe('AllCasesGeneric', () => {
filterStatus: CaseStatuses.open,
handleIsLoading: jest.fn(),
showActions: false,
userCanCrud: true,
})
);
await waitFor(() => {
@ -387,14 +384,17 @@ describe('AllCasesGeneric', () => {
});
});
it('closes case when row action icon clicked', async () => {
it('Updates status when status context menu is updated', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="euiCollapsedItemActionsButton"]').first().simulate('click');
wrapper.find('[data-test-subj="action-close"]').first().simulate('click');
wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).first().simulate('click');
wrapper
.find(`[data-test-subj="case-view-status-dropdown-closed"] button`)
.first()
.simulate('click');
await waitFor(() => {
const firstCase = useGetCasesMockState.data.cases[0];
@ -409,66 +409,6 @@ describe('AllCasesGeneric', () => {
});
});
it('opens case when row action icon clicked', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
data: {
...defaultGetCases.data,
cases: [
{
...defaultGetCases.data.cases[0],
status: CaseStatuses.closed,
},
],
},
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.closed },
});
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="euiCollapsedItemActionsButton"]').first().simulate('click');
wrapper.find('[data-test-subj="action-open"]').first().simulate('click');
await waitFor(() => {
const firstCase = useGetCasesMockState.data.cases[0];
expect(dispatchUpdateCaseProperty.mock.calls[0][0]).toEqual(
expect.objectContaining({
caseId: firstCase.id,
updateKey: 'status',
updateValue: CaseStatuses.open,
version: firstCase.version,
})
);
});
});
it('put case in progress when row action icon clicked', async () => {
const wrapper = mount(
<TestProviders>
<AllCases {...defaultAllCasesProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="euiCollapsedItemActionsButton"]').first().simulate('click');
wrapper.find('[data-test-subj="action-in-progress"]').first().simulate('click');
await waitFor(() => {
const firstCase = useGetCasesMockState.data.cases[0];
expect(dispatchUpdateCaseProperty.mock.calls[0][0]).toEqual(
expect.objectContaining({
caseId: firstCase.id,
updateKey: 'status',
updateValue: CaseStatuses['in-progress'],
version: firstCase.version,
})
);
});
});
it('Bulk delete', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
@ -794,7 +734,7 @@ describe('AllCasesGeneric', () => {
closedAt: null,
closedBy: null,
comments: [],
connector: { fields: null, id: 'none', name: 'My Connector', type: '.none' },
connector: { fields: null, id: '123', name: 'My Connector', type: '.jira' },
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: {
email: 'leslie.knope@elastic.co',

View file

@ -90,10 +90,15 @@ export const REFRESH = i18n.translate('xpack.cases.caseTable.refreshTitle', {
defaultMessage: 'Refresh',
});
export const SERVICENOW_LINK_ARIA = i18n.translate('xpack.cases.caseTable.serviceNowLinkAria', {
defaultMessage: 'click to view the incident on servicenow',
});
export const PUSH_LINK_ARIA = (thirdPartyName: string): string =>
i18n.translate('xpack.cases.caseTable.pushLinkAria', {
values: { thirdPartyName },
defaultMessage: 'click to view the incident on { thirdPartyName }.',
});
export const STATUS = i18n.translate('xpack.cases.caseTable.status', {
defaultMessage: 'Status',
});
export const CHANGE_STATUS = i18n.translate('xpack.cases.caseTable.changeStatus', {
defaultMessage: 'Change status',
});

View file

@ -9,6 +9,7 @@ import React, { memo, useCallback, useMemo, useState } from 'react';
import { EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { caseStatuses, CaseStatuses } from '../../../common';
import { Status } from '../status';
import { CHANGE_STATUS } from '../all_cases/translations';
interface Props {
currentStatus: CaseStatuses;
@ -53,18 +54,17 @@ const StatusContextMenuComponent: React.FC<Props> = ({
);
return (
<>
<EuiPopover
anchorPosition="downLeft"
button={popOverButton}
closePopover={closePopover}
data-test-subj="case-view-status-dropdown"
id="caseStatusPopover"
isOpen={isPopoverOpen}
>
<EuiContextMenuPanel items={panelItems} />
</EuiPopover>
</>
<EuiPopover
anchorPosition="downLeft"
button={popOverButton}
closePopover={closePopover}
data-test-subj="case-view-status-dropdown"
id="caseStatusPopover"
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenuPanel title={CHANGE_STATUS} items={panelItems} />
</EuiPopover>
);
};

View file

@ -67,6 +67,7 @@ describe('EditConnector ', () => {
<EditConnector {...defaultProps} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="has-data-to-push-button"]`).exists()).toBeTruthy();
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
expect(
@ -173,6 +174,8 @@ describe('EditConnector ', () => {
await waitFor(() =>
expect(wrapper.find(`[data-test-subj="connector-edit"]`).exists()).toBeFalsy()
);
expect(wrapper.find(`[data-test-subj="has-data-to-push-button"]`).exists()).toBeFalsy();
});
it('displays the permissions error message when one is provided', async () => {
@ -191,6 +194,8 @@ describe('EditConnector ', () => {
expect(
wrapper.find(`[data-test-subj="edit-connector-no-connectors-msg"]`).exists()
).toBeFalsy();
expect(wrapper.find(`[data-test-subj="has-data-to-push-button"]`).exists()).toBeFalsy();
});
});

View file

@ -330,11 +330,15 @@ export const EditConnector = React.memo(
</EuiFlexGroup>
</EuiFlexItem>
)}
{pushCallouts == null && !isLoading && !editConnector && (
<EuiFlexItem data-test-subj="has-data-to-push-button" grow={false}>
<span>{pushButton}</span>
</EuiFlexItem>
)}
{pushCallouts == null &&
!isLoading &&
!editConnector &&
userCanCrud &&
!permissionsError && (
<EuiFlexItem data-test-subj="has-data-to-push-button" grow={false}>
<span>{pushButton}</span>
</EuiFlexItem>
)}
</MyFlexGroup>
</EuiText>
);

View file

@ -176,6 +176,12 @@ export const basicPush = {
export const pushedCase: Case = {
...basicCase,
connector: {
id: '123',
name: 'My Connector',
type: ConnectorTypes.jira,
fields: null,
},
externalService: basicPush,
};
@ -286,6 +292,12 @@ export const basicPushSnake = {
export const pushedCaseSnake = {
...basicCaseSnake,
connector: {
id: '123',
name: 'My Connector',
type: ConnectorTypes.jira,
fields: null,
},
external_service: basicPushSnake,
};

View file

@ -12,5 +12,6 @@
},
"settings": {
"syncAlerts": true
}
},
"owner": "securitySolution"
}

View file

@ -75,6 +75,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
});
it(`doesn't show read-only badge`, async () => {
await PageObjects.common.navigateToActualUrl('observabilityCases');
await PageObjects.observability.expectNoReadOnlyCallout();
});
@ -142,6 +143,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
});
it(`shows read-only glasses badge`, async () => {
await PageObjects.common.navigateToActualUrl('observabilityCases');
await PageObjects.observability.expectReadOnlyGlassesBadge();
});