[Cases] Tags bulk & row actions (#143450)

## Summary

This PR adds the ability to bulk edit tags from the cases table. It also
adds the tag row action to edit tags on an individual case.

## User flow

https://user-images.githubusercontent.com/7871006/199962211-cb57f34d-1319-4008-b204-7adee623c9e4.mov

## Screenshots
<img width="1922" alt="Screenshot 2022-11-04 at 1 29 15 PM"
src="https://user-images.githubusercontent.com/7871006/199962454-663d163b-f1a6-4fd5-a5df-d2e5b1704f4f.png">
<img width="479" alt="Screenshot 2022-11-04 at 1 30 01 PM"
src="https://user-images.githubusercontent.com/7871006/199962445-b1194e1b-efff-4fdd-8e50-1fa4bef7c960.png">
<img width="482" alt="Screenshot 2022-11-04 at 1 29 29 PM"
src="https://user-images.githubusercontent.com/7871006/199962452-5664a4fd-459d-422c-b08c-c9f00ea88190.png">



### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

## Release notes

Add the ability to bulk edit tags in the cases table
This commit is contained in:
Christos Nasikas 2022-11-08 20:03:00 +02:00 committed by GitHub
parent b24bfb4f25
commit c73dc349ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1495 additions and 4 deletions

View file

@ -0,0 +1,94 @@
/*
* 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 userEvent from '@testing-library/user-event';
import type { AppMockRenderer } from '../../../common/mock';
import { createAppMockRenderer } from '../../../common/mock';
import { basicCase } from '../../../containers/mock';
import { waitForComponentToUpdate } from '../../../common/test_utils';
import { EditTagsFlyout } from './edit_tags_flyout';
import { waitFor } from '@testing-library/dom';
jest.mock('../../../containers/api');
describe('EditTagsFlyout', () => {
let appMock: AppMockRenderer;
/**
* Case one has the following tags: coke, pepsi, one
* Case two has the following tags: one, three
* All available tags are: one, two, three, coke, pepsi
*/
const props = {
selectedCases: [basicCase],
onClose: jest.fn(),
onSaveTags: jest.fn(),
};
beforeEach(() => {
appMock = createAppMockRenderer();
jest.clearAllMocks();
});
it('renders correctly', async () => {
const result = appMock.render(<EditTagsFlyout {...props} />);
expect(result.getByTestId('cases-edit-tags-flyout')).toBeInTheDocument();
expect(result.getByTestId('cases-edit-tags-flyout-title')).toBeInTheDocument();
expect(result.getByTestId('cases-edit-tags-flyout-cancel')).toBeInTheDocument();
expect(result.getByTestId('cases-edit-tags-flyout-submit')).toBeInTheDocument();
await waitForComponentToUpdate();
});
it('calls onClose when pressing the cancel button', async () => {
const result = appMock.render(<EditTagsFlyout {...props} />);
userEvent.click(result.getByTestId('cases-edit-tags-flyout-cancel'));
expect(props.onClose).toHaveBeenCalled();
await waitForComponentToUpdate();
});
it('calls onSaveTags when pressing the save selection button', async () => {
const result = appMock.render(<EditTagsFlyout {...props} />);
await waitForComponentToUpdate();
await waitFor(() => {
expect(result.getByText('coke')).toBeInTheDocument();
});
userEvent.click(result.getByText('coke'));
userEvent.click(result.getByTestId('cases-edit-tags-flyout-submit'));
expect(props.onSaveTags).toHaveBeenCalledWith({
selectedTags: ['pepsi'],
unSelectedTags: ['coke'],
});
});
it('shows the case title when selecting one case', async () => {
const result = appMock.render(<EditTagsFlyout {...props} />);
expect(result.getByText(basicCase.title)).toBeInTheDocument();
await waitForComponentToUpdate();
});
it('shows the number of total selected cases in the title when selecting multiple cases', async () => {
const result = appMock.render(
<EditTagsFlyout {...props} selectedCases={[basicCase, basicCase]} />
);
expect(result.getByText('Selected cases: 2')).toBeInTheDocument();
await waitForComponentToUpdate();
});
});

View file

@ -0,0 +1,111 @@
/*
* 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, { useCallback, useState } from 'react';
import { css } from '@emotion/react';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
euiFullHeight,
EuiLoadingSpinner,
EuiText,
EuiTitle,
} from '@elastic/eui';
import type { Case } from '../../../../common';
import { useGetTags } from '../../../containers/use_get_tags';
import { EditTagsSelectable } from './edit_tags_selectable';
import * as i18n from './translations';
import type { TagsSelectionState } from './types';
interface Props {
selectedCases: Case[];
onClose: () => void;
onSaveTags: (args: TagsSelectionState) => void;
}
const fullHeight = css`
${euiFullHeight()}
.euiFlyoutBody__overflowContent {
${euiFullHeight()}
}
`;
const EditTagsFlyoutComponent: React.FC<Props> = ({ selectedCases, onClose, onSaveTags }) => {
const { data: tags, isLoading } = useGetTags();
const [tagsSelection, setTagsSelection] = useState<TagsSelectionState>({
selectedTags: [],
unSelectedTags: [],
});
const onSave = useCallback(() => onSaveTags(tagsSelection), [onSaveTags, tagsSelection]);
const headerSubtitle =
selectedCases.length > 1 ? i18n.SELECTED_CASES(selectedCases.length) : selectedCases[0].title;
return (
<EuiFlyout
ownFocus
onClose={onClose}
aria-labelledby="cases-edit-tags-flyout"
data-test-subj="cases-edit-tags-flyout"
size="s"
paddingSize="m"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 data-test-subj="cases-edit-tags-flyout-title">{i18n.EDIT_TAGS}</h2>
</EuiTitle>
<EuiText color="subdued">
<p>{headerSubtitle}</p>
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody css={fullHeight}>
{isLoading ? (
<EuiLoadingSpinner />
) : (
<EditTagsSelectable
selectedCases={selectedCases}
isLoading={isLoading}
tags={tags ?? []}
onChangeTags={setTagsSelection}
/>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={onClose}
flush="left"
data-test-subj="cases-edit-tags-flyout-cancel"
>
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={onSave} fill data-test-subj="cases-edit-tags-flyout-submit">
{i18n.SAVE_SELECTION}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};
EditTagsFlyoutComponent.displayName = 'EditTagsFlyout';
export const EditTagsFlyout = React.memo(EditTagsFlyoutComponent);

View file

@ -0,0 +1,278 @@
/*
* 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 type { AppMockRenderer } from '../../../common/mock';
import { createAppMockRenderer } from '../../../common/mock';
import { EditTagsSelectable } from './edit_tags_selectable';
import { basicCase } from '../../../containers/mock';
import { waitForComponentToUpdate } from '../../../common/test_utils';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
describe('EditTagsSelectable', () => {
let appMock: AppMockRenderer;
/**
* Case has the following tags: coke, pepsi
* All available tags are: one, two, coke, pepsi
*/
const props = {
selectedCases: [basicCase],
isLoading: false,
tags: ['one', 'two', ...basicCase.tags],
onChangeTags: jest.fn(),
};
/**
* Case one has the following tags: coke, pepsi, one
* Case two has the following tags: one, three
* All available tags are: one, two, three, coke, pepsi
*/
const propsMultipleCases = {
selectedCases: [
{ ...basicCase, tags: [...basicCase.tags, 'one'] },
{ ...basicCase, tags: ['one', 'three'] },
],
isLoading: false,
tags: ['one', 'two', 'three', ...basicCase.tags],
onChangeTags: jest.fn(),
};
beforeEach(() => {
appMock = createAppMockRenderer();
jest.clearAllMocks();
});
it('renders correctly', async () => {
const result = appMock.render(<EditTagsSelectable {...props} />);
expect(result.getByTestId('cases-actions-tags-edit-selectable')).toBeInTheDocument();
expect(result.getByPlaceholderText('Search')).toBeInTheDocument();
expect(result.getByText(`Total tags: ${props.tags.length}`)).toBeInTheDocument();
expect(result.getByText('Selected: 2')).toBeInTheDocument();
expect(result.getByText('Select all')).toBeInTheDocument();
expect(result.getByText('Select none')).toBeInTheDocument();
for (const tag of props.tags) {
expect(result.getByText(tag)).toBeInTheDocument();
}
await waitForComponentToUpdate();
});
it('renders the selected tags label correctly', async () => {
const result = appMock.render(<EditTagsSelectable {...propsMultipleCases} />);
expect(result.getByText('Total tags: 5')).toBeInTheDocument();
expect(result.getByText('Selected: 4')).toBeInTheDocument();
for (const tag of props.tags) {
expect(result.getByText(tag)).toBeInTheDocument();
}
await waitForComponentToUpdate();
});
it('renders the tags icons correctly', async () => {
const result = appMock.render(<EditTagsSelectable {...propsMultipleCases} />);
for (const [tag, icon] of [
['one', 'check'],
['two', 'empty'],
['three', 'asterisk'],
['coke', 'asterisk'],
['pepsi', 'asterisk'],
]) {
const iconDataTestSubj = `cases-actions-tags-edit-selectable-tag-${tag}-icon-${icon}`;
expect(result.getByTestId(iconDataTestSubj)).toBeInTheDocument();
}
await waitForComponentToUpdate();
});
it('selects and unselects correctly tags with one case', async () => {
const result = appMock.render(<EditTagsSelectable {...props} />);
for (const tag of props.tags) {
userEvent.click(result.getByText(tag));
}
expect(props.onChangeTags).toBeCalledTimes(props.tags.length);
expect(props.onChangeTags).nthCalledWith(props.tags.length, {
selectedTags: ['one', 'two'],
unSelectedTags: ['coke', 'pepsi'],
});
await waitForComponentToUpdate();
});
it('selects and unselects correctly tags with multiple cases', async () => {
const result = appMock.render(<EditTagsSelectable {...propsMultipleCases} />);
for (const tag of propsMultipleCases.tags) {
userEvent.click(result.getByText(tag));
}
expect(propsMultipleCases.onChangeTags).toBeCalledTimes(propsMultipleCases.tags.length);
expect(propsMultipleCases.onChangeTags).nthCalledWith(propsMultipleCases.tags.length, {
selectedTags: ['two', 'three', 'coke', 'pepsi'],
unSelectedTags: ['one'],
});
await waitForComponentToUpdate();
});
it('renders the icons correctly after selecting and deselecting tags', async () => {
const result = appMock.render(<EditTagsSelectable {...propsMultipleCases} />);
for (const tag of propsMultipleCases.tags) {
userEvent.click(result.getByText(tag));
}
for (const [tag, icon] of [
['one', 'empty'],
['two', 'check'],
['three', 'check'],
['coke', 'check'],
['pepsi', 'check'],
]) {
const iconDataTestSubj = `cases-actions-tags-edit-selectable-tag-${tag}-icon-${icon}`;
expect(result.getByTestId(iconDataTestSubj)).toBeInTheDocument();
}
expect(propsMultipleCases.onChangeTags).toBeCalledTimes(propsMultipleCases.tags.length);
expect(propsMultipleCases.onChangeTags).nthCalledWith(propsMultipleCases.tags.length, {
selectedTags: ['two', 'three', 'coke', 'pepsi'],
unSelectedTags: ['one'],
});
await waitForComponentToUpdate();
});
it('adds a new tag correctly', async () => {
const result = appMock.render(<EditTagsSelectable {...props} />);
await userEvent.type(result.getByPlaceholderText('Search'), 'not-exist', { delay: 1 });
await waitFor(() => {
expect(
result.getByTestId('cases-actions-tags-edit-selectable-add-new-tag')
).toBeInTheDocument();
});
const addNewTagButton = result.getByTestId('cases-actions-tags-edit-selectable-add-new-tag');
userEvent.click(addNewTagButton);
await waitForComponentToUpdate();
expect(props.onChangeTags).toBeCalledTimes(1);
expect(props.onChangeTags).nthCalledWith(1, {
selectedTags: ['not-exist', 'coke', 'pepsi'],
unSelectedTags: [],
});
});
it('selects all tags correctly', async () => {
const result = appMock.render(<EditTagsSelectable {...propsMultipleCases} />);
expect(result.getByText('Select all')).toBeInTheDocument();
userEvent.click(result.getByText('Select all'));
expect(propsMultipleCases.onChangeTags).toBeCalledTimes(1);
expect(propsMultipleCases.onChangeTags).nthCalledWith(1, {
selectedTags: propsMultipleCases.tags,
unSelectedTags: [],
});
await waitForComponentToUpdate();
});
it('unselects all tags correctly', async () => {
const result = appMock.render(<EditTagsSelectable {...propsMultipleCases} />);
expect(result.getByText('Select all')).toBeInTheDocument();
userEvent.click(result.getByText('Select none'));
expect(propsMultipleCases.onChangeTags).toBeCalledTimes(1);
expect(propsMultipleCases.onChangeTags).nthCalledWith(1, {
selectedTags: [],
unSelectedTags: ['one', 'three', 'coke', 'pepsi'],
});
await waitForComponentToUpdate();
});
it('unselects correctly with the new item presented', async () => {
const result = appMock.render(<EditTagsSelectable {...propsMultipleCases} />);
/**
* Tag with label "one" exist. Searching for "on" will show both the
* "add new tag" item and the "one" tag
*/
await userEvent.type(result.getByPlaceholderText('Search'), 'on', { delay: 1 });
await waitFor(() => {
expect(
result.getByTestId('cases-actions-tags-edit-selectable-add-new-tag')
).toBeInTheDocument();
});
const iconDataTestSubj = 'cases-actions-tags-edit-selectable-tag-one-icon-check';
expect(result.getByTestId(iconDataTestSubj)).toBeInTheDocument();
userEvent.click(result.getByTestId(iconDataTestSubj));
await waitForComponentToUpdate();
expect(propsMultipleCases.onChangeTags).toBeCalledTimes(1);
expect(propsMultipleCases.onChangeTags).nthCalledWith(1, {
selectedTags: [],
unSelectedTags: ['one'],
});
});
it('adds a partial match correctly', async () => {
const result = appMock.render(<EditTagsSelectable {...props} />);
/**
* Tag with label "one" exist. Searching for "on" will show both the
* "add new tag" item and the "one" tag
*/
await userEvent.type(result.getByPlaceholderText('Search'), 'on', { delay: 1 });
await waitFor(() => {
expect(
result.getByTestId('cases-actions-tags-edit-selectable-add-new-tag')
).toBeInTheDocument();
});
const addNewTagButton = result.getByTestId('cases-actions-tags-edit-selectable-add-new-tag');
userEvent.click(addNewTagButton);
await waitForComponentToUpdate();
expect(props.onChangeTags).toBeCalledTimes(1);
expect(props.onChangeTags).nthCalledWith(1, {
selectedTags: ['on', 'coke', 'pepsi'],
unSelectedTags: [],
});
});
it('do not show the new item option on exact match', async () => {
const result = appMock.render(<EditTagsSelectable {...props} />);
await userEvent.type(result.getByPlaceholderText('Search'), 'one', { delay: 1 });
await waitForComponentToUpdate();
expect(
result.queryByTestId('cases-actions-tags-edit-selectable-add-new-tag')
).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,495 @@
/*
* 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, { useCallback, useMemo, useReducer, useState, useEffect } from 'react';
import type { EuiSelectableOption, IconType } from '@elastic/eui';
import {
EuiSelectable,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiButtonEmpty,
EuiHorizontalRule,
EuiIcon,
EuiHighlight,
EuiSelectableListItem,
useEuiTheme,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { assertNever } from '@kbn/std';
import { isEmpty } from 'lodash';
import type { Case } from '../../../../common';
import * as i18n from './translations';
import type { TagsSelectionState } from './types';
interface Props {
selectedCases: Case[];
tags: string[];
isLoading: boolean;
onChangeTags: (args: TagsSelectionState) => void;
}
type TagSelectableOption = EuiSelectableOption<{ tagIcon: IconType; newItem?: boolean }>;
const enum TagState {
CHECKED = 'checked',
PARTIAL = 'partial',
UNCHECKED = 'unchecked',
}
const enum Actions {
CHECK_TAG,
UNCHECK_TAG,
}
const enum ICONS {
CHECKED = 'check',
PARTIAL = 'asterisk',
UNCHECKED = 'empty',
}
type Action =
| { type: Actions.CHECK_TAG; payload: string[] }
| { type: Actions.UNCHECK_TAG; payload: string[] };
interface Tag {
tagState: TagState;
dirty: boolean;
icon: IconType;
}
interface State {
tags: Record<string, Tag>;
tagCounterMap: Map<string, number>;
}
const stateToIconMap: Record<TagState, ICONS> = {
[TagState.CHECKED]: ICONS.CHECKED,
[TagState.PARTIAL]: ICONS.PARTIAL,
[TagState.UNCHECKED]: ICONS.UNCHECKED,
};
/**
* The EuiSelectable has two states values for its items: checked="on" for checked items
* and check=undefined for unchecked items. Given that our use case needs
* to track tags that are part in some cases and not part in some others we need
* to keep our own state and sync it with the EuiSelectable. Our state is always
* the source of true.
*
* In our state, a tag can be in one of the following states: checked, partial, and unchecked.
* A checked tag is a tag that is either common in all cases or has been
* checked by the user. A partial tag is a tag that is available is some of the
* selected cases and not available in others. A user can not make a tag partial.
* A unchecked tag is a tag that is either unselected by the user or is not available
* in all selected cases.
*
* State transitions:
*
* partial --> checked
* checked --> unchecked
* unchecked --> checked
*
* A dirty tag is a tag that the user clicked. Because the EuiSelectable
* returns all items (tags) on each user interaction we need to distinguish tags
* that the user unselected from tags that are not common between all selected cases
* and the user did not interact with them. Marking tags as dirty help us to do that.
* A user to unselect a tag needs to fist checked a partial or an unselected tag and make it
* selected (and dirty). This guarantees that unchecked tags will always become dirty at some
* point in the past.
*
* On mount (initial state) the component gets all available tags.
* The tags that are common in all selected cases are marked as checked
* and dirty in our state and checked in EuiSelectable state.
* The ones that are not common in any of the selected tags are
* marked as unchecked and not dirty in our state and unchecked in EuiSelectable state.
* The tags that are common in some of the cases are marked as partial and not dirty
* in our state and unchecked in EuiSelectable state.
*
* When a user interacts with a tag the following happens:
* a) If the tag is unchecked the EuiSelectable marks it as checked and
* we change the state of the tag as checked and dirty.
* b) If the tag is partial the EuiSelectable marks it as checked and
* we change the state of the tag as checked and dirty.
* c) If the tag is checked the EuiSelectable marks it as unchecked and
* we change the state of the tag as unchecked and dirty.
*/
const tagsReducer: React.Reducer<State, Action> = (state: State, action): State => {
switch (action.type) {
case Actions.CHECK_TAG:
const selectedTags: State['tags'] = {};
for (const tag of action.payload) {
selectedTags[tag] = { tagState: TagState.CHECKED, dirty: true, icon: ICONS.CHECKED };
}
return { ...state, tags: { ...state.tags, ...selectedTags } };
case Actions.UNCHECK_TAG:
const unselectedTags: State['tags'] = {};
for (const tag of action.payload) {
unselectedTags[tag] = { tagState: TagState.UNCHECKED, dirty: true, icon: ICONS.UNCHECKED };
}
return { ...state, tags: { ...state.tags, ...unselectedTags } };
default:
assertNever(action);
}
};
const getInitialTagsState = ({
tags,
selectedCases,
}: {
tags: string[];
selectedCases: Case[];
}): State => {
const tagCounterMap = createTagsCounterMapping(selectedCases);
const totalCases = selectedCases.length;
const tagsRecord: State['tags'] = {};
const state = { tags: tagsRecord, tagCounterMap };
for (const tag of tags) {
const tagCounter = tagCounterMap.get(tag) ?? 0;
const isCheckedTag = tagCounter === totalCases;
const isPartialTag = tagCounter < totalCases && tagCounter !== 0;
const tagState = isCheckedTag
? TagState.CHECKED
: isPartialTag
? TagState.PARTIAL
: TagState.UNCHECKED;
const icon = getSelectionIcon(tagState);
tagsRecord[tag] = { tagState, dirty: isCheckedTag, icon };
}
return state;
};
const createTagsCounterMapping = (selectedCases: Case[]) => {
const counterMap = new Map<string, number>();
for (const theCase of selectedCases) {
const caseTags = theCase.tags;
for (const tag of caseTags) {
counterMap.set(tag, (counterMap.get(tag) ?? 0) + 1);
}
}
return counterMap;
};
const stateToOptions = (tagsState: State['tags']): TagSelectableOption[] => {
const tags = Object.keys(tagsState);
return tags.map((tag): EuiSelectableOption => {
return {
key: tag,
label: tag,
...(tagsState[tag].tagState === TagState.CHECKED ? { checked: 'on' } : {}),
'data-test-subj': `cases-actions-tags-edit-selectable-tag-${tag}`,
data: { tagIcon: tagsState[tag].icon },
};
}) as TagSelectableOption[];
};
const getSelectionIcon = (tagState: TagState): ICONS => {
return stateToIconMap[tagState];
};
const getSelectedAndUnselectedTags = (newOptions: EuiSelectableOption[], tags: State['tags']) => {
const selectedTags: string[] = [];
const unSelectedTags: string[] = [];
for (const option of newOptions) {
if (option.checked === 'on') {
selectedTags.push(option.label);
}
/**
* User can only select the "Add new tag" item. Because a new item do not have a state yet
* we need to ensure that state access is done only by options with state.
*/
if (
!option.data?.newItem &&
!option.checked &&
tags[option.label] &&
tags[option.label].dirty
) {
unSelectedTags.push(option.label);
}
}
return { selectedTags, unSelectedTags };
};
const hasExactMatch = (searchValue: string, options: TagSelectableOption[]) => {
return options.some((option) => option.key === searchValue);
};
const AddNewTagItem: React.FC<{ searchValue: string; onNewItem: (newTag: string) => void }> =
React.memo(({ searchValue, onNewItem }) => {
const onNewTagClick = useCallback(() => {
onNewItem(searchValue);
}, [onNewItem, searchValue]);
return (
<EuiSelectableListItem
isFocused={false}
showIcons={false}
onClick={onNewTagClick}
data-test-subj="cases-actions-tags-edit-selectable-add-new-tag"
>
<FormattedMessage
id="xpack.cases.actions.tags.newTagMessage"
defaultMessage="Add {searchValue} as a tag"
values={{ searchValue: <b>{searchValue}</b> }}
/>
</EuiSelectableListItem>
);
});
AddNewTagItem.displayName = 'AddNewTagItem';
const EditTagsSelectableComponent: React.FC<Props> = ({
selectedCases,
tags,
isLoading,
onChangeTags,
}) => {
/**
* If react query refetch on the background and fetches new tags the component will
* rerender but it will not change the state. getInitialTagsState will run only on
* mount. This is a desired behaviour because it prevents the list of tags for changing
* while the user interacts with the selectable.
*/
const [state, dispatch] = useReducer(tagsReducer, { tags, selectedCases }, getInitialTagsState);
const [searchValue, setSearchValue] = useState<string>('');
const { euiTheme } = useEuiTheme();
const options: TagSelectableOption[] = useMemo(() => stateToOptions(state.tags), [state.tags]);
const renderOption = useCallback((option: TagSelectableOption, search: string) => {
const dataTestSubj = option.newItem
? 'cases-actions-tags-edit-selectable-add-new-tag-icon'
: `cases-actions-tags-edit-selectable-tag-${option.label}-icon-${option.tagIcon}`;
return (
<>
<EuiIcon
type={option.tagIcon}
data-test-subj={dataTestSubj}
className="euiSelectableListItem__icon euiSelectableListItem__prepend"
/>
<EuiHighlight search={search}>{option.label}</EuiHighlight>
</>
);
}, []);
const onChange = useCallback(
(newOptions: EuiSelectableOption[]) => {
/**
* In this function the user has selected and deselected some tags. If the user
* pressed the "add new tag" option it means that needs to add the new tag to the list.
* Because the label of the "add new tag" item is "Add ${searchValue} as a tag" we need to
* change the label to the same as the tag the user entered. The key will always be the
* search term (aka the new label).
*/
const normalizeOptions = newOptions.map((option) => {
if (option.data?.newItem) {
return {
...option,
label: option.key ?? '',
};
}
return option;
});
const { selectedTags, unSelectedTags } = getSelectedAndUnselectedTags(
normalizeOptions,
state.tags
);
dispatch({ type: Actions.CHECK_TAG, payload: selectedTags });
dispatch({ type: Actions.UNCHECK_TAG, payload: unSelectedTags });
onChangeTags({ selectedTags, unSelectedTags });
},
[onChangeTags, state.tags]
);
const onNewItem = useCallback(
(newTag: string) => {
const { selectedTags, unSelectedTags } = getSelectedAndUnselectedTags(options, state.tags);
dispatch({ type: Actions.CHECK_TAG, payload: [newTag] });
setSearchValue('');
onChangeTags({ selectedTags: [...selectedTags, newTag], unSelectedTags });
},
[onChangeTags, options, state.tags]
);
const onSelectAll = useCallback(() => {
dispatch({ type: Actions.CHECK_TAG, payload: Object.keys(state.tags) });
onChangeTags({ selectedTags: Object.keys(state.tags), unSelectedTags: [] });
}, [onChangeTags, state.tags]);
const onSelectNone = useCallback(() => {
const unSelectedTags = [];
for (const [label, tag] of Object.entries(state.tags)) {
if (tag.tagState === TagState.CHECKED || tag.tagState === TagState.PARTIAL) {
unSelectedTags.push(label);
}
}
dispatch({ type: Actions.UNCHECK_TAG, payload: unSelectedTags });
onChangeTags({ selectedTags: [], unSelectedTags });
}, [state.tags, onChangeTags]);
const onSearchChange = useCallback((value) => {
setSearchValue(value);
}, []);
/**
* TODO: Remove hack when PR https://github.com/elastic/eui/pull/6317
* is merged and the new fix is merged into Kibana.
*
* This is a hack to force a rerender when
* the user adds a new tag. There is a bug in
* the EuiSelectable where a race condition that's causing the search bar
* to not to match terms with the empty string to trigger the reload.
* This means that when a user press the button to add a tag the
* search bar clears but the options are not shown.
*/
const [_, setRerender] = useState(0);
useEffect(() => {
if (isEmpty(searchValue)) {
setRerender((x) => x + 1);
}
}, [options, setRerender, searchValue]);
/**
* While the user searches we need to add the ability
* to add the search term as a new tag. The no matches message
* is not enough because a search term can partial match to some tags
* but the user will still need to add the search term as tag.
* For that reason, we always add a "fake" option ("add new tag" option) which will serve as a
* the button with which the user can add a new tag. We do not show
* the "add new tag" option if there is an exact match.
*/
const optionsWithAddNewTagOption = useMemo(() => {
if (!isEmpty(searchValue) && !hasExactMatch(searchValue, options)) {
return [
{
key: searchValue,
searchableLabel: searchValue,
label: `Add ${searchValue} as a tag`,
'data-test-subj': 'cases-actions-tags-edit-selectable-add-new-tag',
data: { tagIcon: 'empty', newItem: true },
},
...options,
] as TagSelectableOption[];
}
return options;
}, [options, searchValue]);
const selectedTags = Object.values(state.tags).filter(
(tag) => tag.tagState === TagState.CHECKED || tag.tagState === TagState.PARTIAL
).length;
return (
<EuiSelectable
options={optionsWithAddNewTagOption}
searchable
searchProps={{
placeholder: i18n.SEARCH_PLACEHOLDER,
isLoading,
isClearable: !isLoading,
onChange: onSearchChange,
value: searchValue,
'data-test-subj': 'cases-actions-tags-edit-selectable-search-input',
}}
renderOption={renderOption}
listProps={{ showIcons: false }}
onChange={onChange}
noMatchesMessage={<AddNewTagItem searchValue={searchValue ?? ''} onNewItem={onNewItem} />}
data-test-subj="cases-actions-tags-edit-selectable"
height="full"
>
{(list, search) => (
<>
{search}
<EuiSpacer size="s" />
<EuiFlexGroup
alignItems="center"
justifyContent="spaceBetween"
responsive={false}
direction="row"
css={{ flexGrow: 0 }}
gutterSize="none"
>
<EuiFlexItem
grow={false}
css={{
borderRight: euiTheme.border.thin,
paddingRight: euiTheme.size.s,
}}
>
<EuiText size="xs" color="subdued">
{i18n.TOTAL_TAGS(tags.length)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem
grow={false}
css={{
paddingLeft: euiTheme.size.s,
}}
>
<EuiText size="xs" color="subdued">
{i18n.SELECTED_TAGS(selectedTags)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false} css={{ marginLeft: 'auto' }}>
<EuiFlexGroup
responsive={false}
direction="row"
alignItems="center"
justifyContent="flexEnd"
gutterSize="xs"
>
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="xs" flush="right" onClick={onSelectAll}>
{i18n.SELECT_ALL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="xs" flush="right" onClick={onSelectNone}>
{i18n.SELECT_NONE}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="m" />
{list}
</>
)}
</EuiSelectable>
);
};
EditTagsSelectableComponent.displayName = 'EditTagsSelectable';
export const EditTagsSelectable = React.memo(EditTagsSelectableComponent);

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export { CANCEL } from '../../../common/translations';
export const EDIT_TAGS = i18n.translate('xpack.cases.actions.tags.edit', {
defaultMessage: 'Edit tags',
});
export const SAVE_SELECTION = i18n.translate('xpack.cases.actions.tags.saveSelection', {
defaultMessage: 'Save selection',
});
export const TOTAL_TAGS = (totalTags: number) =>
i18n.translate('xpack.cases.actions.tags.totalTags', {
defaultMessage: 'Total tags: {totalTags}',
values: { totalTags },
});
export const SELECT_ALL = i18n.translate('xpack.cases.actions.tags.selectAll', {
defaultMessage: 'Select all',
});
export const SELECT_NONE = i18n.translate('xpack.cases.actions.tags.selectNone', {
defaultMessage: 'Select none',
});
export const SEARCH_PLACEHOLDER = i18n.translate('xpack.cases.actions.tags.searchPlaceholder', {
defaultMessage: 'Search',
});
export const EDITED_TAGS = (totalCases: number) =>
i18n.translate('xpack.cases.containers.editedCases', {
values: { totalCases },
defaultMessage: 'Edited {totalCases, plural, =1 {case} other {{totalCases} cases}}',
});
export const SELECTED_CASES = (totalCases: number) =>
i18n.translate('xpack.cases.actions.tags.headerSubtitle', {
values: { totalCases },
defaultMessage: 'Selected cases: {totalCases}',
});
export const SELECTED_TAGS = (selectedTags: number) =>
i18n.translate('xpack.cases.actions.tags.selectedTags', {
defaultMessage: 'Selected: {selectedTags}',
values: { selectedTags },
});

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface TagsSelectionState {
selectedTags: string[];
unSelectedTags: string[];
}

View file

@ -0,0 +1,196 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AppMockRenderer } from '../../../common/mock';
import { createAppMockRenderer } from '../../../common/mock';
import { act, renderHook } from '@testing-library/react-hooks';
import { useTagsAction } from './use_tags_action';
import * as api from '../../../containers/api';
import { basicCase } from '../../../containers/mock';
jest.mock('../../../containers/api');
describe('useTagsAction', () => {
let appMockRender: AppMockRenderer;
const onAction = jest.fn();
const onActionSuccess = jest.fn();
beforeEach(() => {
appMockRender = createAppMockRenderer();
jest.clearAllMocks();
});
it('renders an action', async () => {
const { result } = renderHook(
() =>
useTagsAction({
onAction,
onActionSuccess,
isDisabled: false,
}),
{
wrapper: appMockRender.AppWrapper,
}
);
expect(result.current.getAction([basicCase])).toMatchInlineSnapshot(`
Object {
"data-test-subj": "cases-bulk-action-tags",
"disabled": false,
"icon": <EuiIcon
size="m"
type="tag"
/>,
"key": "cases-bulk-action-tags",
"name": "Edit tags",
"onClick": [Function],
}
`);
});
it('closes the flyout', async () => {
const { result, waitFor } = renderHook(
() => useTagsAction({ onAction, onActionSuccess, isDisabled: false }),
{
wrapper: appMockRender.AppWrapper,
}
);
const action = result.current.getAction([basicCase]);
act(() => {
action.onClick();
});
expect(result.current.isFlyoutOpen).toBe(true);
act(() => {
result.current.onFlyoutClosed();
});
await waitFor(() => {
expect(result.current.isFlyoutOpen).toBe(false);
});
});
it('update the tags correctly', async () => {
const updateSpy = jest.spyOn(api, 'updateCases');
const { result, waitFor } = renderHook(
() => useTagsAction({ onAction, onActionSuccess, isDisabled: false }),
{
wrapper: appMockRender.AppWrapper,
}
);
const action = result.current.getAction([basicCase]);
act(() => {
action.onClick();
});
expect(onAction).toHaveBeenCalled();
expect(result.current.isFlyoutOpen).toBe(true);
act(() => {
result.current.onSaveTags({ selectedTags: ['one'], unSelectedTags: ['pepsi'] });
});
await waitFor(() => {
expect(result.current.isFlyoutOpen).toBe(false);
expect(onActionSuccess).toHaveBeenCalled();
expect(updateSpy).toHaveBeenCalledWith(
[{ tags: ['coke', 'one'], id: basicCase.id, version: basicCase.version }],
expect.anything()
);
});
});
it('removes duplicates', async () => {
const updateSpy = jest.spyOn(api, 'updateCases');
const { result, waitFor } = renderHook(
() => useTagsAction({ onAction, onActionSuccess, isDisabled: false }),
{
wrapper: appMockRender.AppWrapper,
}
);
const action = result.current.getAction([basicCase]);
act(() => {
action.onClick();
});
expect(onAction).toHaveBeenCalled();
expect(result.current.isFlyoutOpen).toBe(true);
act(() => {
result.current.onSaveTags({ selectedTags: ['one', 'one'], unSelectedTags: ['pepsi'] });
});
await waitFor(() => {
expect(result.current.isFlyoutOpen).toBe(false);
expect(onActionSuccess).toHaveBeenCalled();
expect(updateSpy).toHaveBeenCalledWith(
[{ tags: ['coke', 'one'], id: basicCase.id, version: basicCase.version }],
expect.anything()
);
});
});
it('shows the success toaster correctly when updating one case', async () => {
const { result, waitFor } = renderHook(
() => useTagsAction({ onAction, onActionSuccess, isDisabled: false }),
{
wrapper: appMockRender.AppWrapper,
}
);
const action = result.current.getAction([basicCase]);
act(() => {
action.onClick();
});
act(() => {
result.current.onSaveTags({ selectedTags: ['one', 'one'], unSelectedTags: ['pepsi'] });
});
await waitFor(() => {
expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith(
'Edited case'
);
});
});
it('shows the success toaster correctly when updating multiple cases', async () => {
const { result, waitFor } = renderHook(
() => useTagsAction({ onAction, onActionSuccess, isDisabled: false }),
{
wrapper: appMockRender.AppWrapper,
}
);
const action = result.current.getAction([basicCase, basicCase]);
act(() => {
action.onClick();
});
act(() => {
result.current.onSaveTags({ selectedTags: ['one', 'one'], unSelectedTags: ['pepsi'] });
});
await waitFor(() => {
expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith(
'Edited 2 cases'
);
});
});
});

View file

@ -0,0 +1,76 @@
/*
* 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 { EuiIcon } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import { difference } from 'lodash';
import { useUpdateCases } from '../../../containers/use_bulk_update_case';
import type { Case } from '../../../../common';
import { useCasesContext } from '../../cases_context/use_cases_context';
import type { UseActionProps } from '../types';
import * as i18n from './translations';
import type { TagsSelectionState } from './types';
export const useTagsAction = ({ onAction, onActionSuccess, isDisabled }: UseActionProps) => {
const { mutate: updateCases } = useUpdateCases();
const { permissions } = useCasesContext();
const [isFlyoutOpen, setIsFlyoutOpen] = useState<boolean>(false);
const [selectedCasesToEditTags, setSelectedCasesToEditTags] = useState<Case[]>([]);
const canUpdateStatus = permissions.update;
const isActionDisabled = isDisabled || !canUpdateStatus;
const onFlyoutClosed = useCallback(() => setIsFlyoutOpen(false), []);
const openFlyout = useCallback(
(selectedCases: Case[]) => {
onAction();
setIsFlyoutOpen(true);
setSelectedCasesToEditTags(selectedCases);
},
[onAction]
);
const onSaveTags = useCallback(
(tagsSelection: TagsSelectionState) => {
onAction();
onFlyoutClosed();
const casesToUpdate = selectedCasesToEditTags.map((theCase) => {
const tags = difference(theCase.tags, tagsSelection.unSelectedTags);
const uniqueTags = new Set([...tags, ...tagsSelection.selectedTags]);
return {
tags: Array.from(uniqueTags.values()),
id: theCase.id,
version: theCase.version,
};
});
updateCases(
{
cases: casesToUpdate,
successToasterTitle: i18n.EDITED_TAGS(selectedCasesToEditTags.length),
},
{ onSuccess: onActionSuccess }
);
},
[onAction, onActionSuccess, onFlyoutClosed, selectedCasesToEditTags, updateCases]
);
const getAction = (selectedCases: Case[]) => {
return {
name: i18n.EDIT_TAGS,
onClick: () => openFlyout(selectedCases),
disabled: isActionDisabled,
'data-test-subj': 'cases-bulk-action-tags',
icon: <EuiIcon type="tag" size="m" />,
key: 'cases-bulk-action-tags',
};
};
return { getAction, isFlyoutOpen, onFlyoutClosed, onSaveTags };
};
export type UseTagsAction = ReturnType<typeof useTagsAction>;

View file

@ -23,6 +23,8 @@ import { statuses } from '../status';
import { useCasesContext } from '../cases_context/use_cases_context';
import { useSeverityAction } from '../actions/severity/use_severity_action';
import { severities } from '../severity/config';
import { useTagsAction } from '../actions/tags/use_tags_action';
import { EditTagsFlyout } from '../actions/tags/edit_tags_flyout';
const ActionColumnComponent: React.FC<{ theCase: Case; disableActions: boolean }> = ({
theCase,
@ -53,6 +55,12 @@ const ActionColumnComponent: React.FC<{ theCase: Case; disableActions: boolean }
selectedSeverity: theCase.severity,
});
const tagsAction = useTagsAction({
isDisabled: false,
onAction: closePopover,
onActionSuccess: refreshCases,
});
const canDelete = deleteAction.canDelete;
const canUpdate = statusAction.canUpdateStatus;
@ -105,6 +113,10 @@ const ActionColumnComponent: React.FC<{ theCase: Case; disableActions: boolean }
});
}
if (canUpdate) {
mainPanelItems.push(tagsAction.getAction([theCase]));
}
if (canDelete) {
mainPanelItems.push(deleteAction.getAction([theCase]));
}
@ -124,7 +136,7 @@ const ActionColumnComponent: React.FC<{ theCase: Case; disableActions: boolean }
}
return panelsToBuild;
}, [canDelete, canUpdate, deleteAction, severityAction, statusAction, theCase]);
}, [canDelete, canUpdate, deleteAction, severityAction, statusAction, tagsAction, theCase]);
return (
<>
@ -157,6 +169,13 @@ const ActionColumnComponent: React.FC<{ theCase: Case; disableActions: boolean }
onConfirm={deleteAction.onConfirmDeletion}
/>
) : null}
{tagsAction.isFlyoutOpen ? (
<EditTagsFlyout
onClose={tagsAction.onFlyoutClosed}
selectedCases={[theCase]}
onSaveTags={tagsAction.onSaveTags}
/>
) : null}
</>
);
};

View file

@ -69,6 +69,17 @@ describe('useBulkActions', () => {
"isSeparator": true,
"key": "bulk-actions-separator",
},
Object {
"data-test-subj": "cases-bulk-action-tags",
"disabled": false,
"icon": <EuiIcon
size="m"
type="tag"
/>,
"key": "cases-bulk-action-tags",
"name": "Edit tags",
"onClick": [Function],
},
Object {
"data-test-subj": "cases-bulk-action-delete",
"disabled": false,

View file

@ -15,6 +15,8 @@ import type { Case } from '../../containers/types';
import { useDeleteAction } from '../actions/delete/use_delete_action';
import { useSeverityAction } from '../actions/severity/use_severity_action';
import { useStatusAction } from '../actions/status/use_status_action';
import { EditTagsFlyout } from '../actions/tags/edit_tags_flyout';
import { useTagsAction } from '../actions/tags/use_tags_action';
import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
import * as i18n from './translations';
@ -54,6 +56,12 @@ export const useBulkActions = ({
onActionSuccess,
});
const tagsAction = useTagsAction({
isDisabled,
onAction,
onActionSuccess,
});
const canDelete = deleteAction.canDelete;
const canUpdate = statusAction.canUpdateStatus;
@ -94,6 +102,10 @@ export const useBulkActions = ({
});
}
if (canUpdate) {
mainPanelItems.push(tagsAction.getAction(selectedCases));
}
if (canDelete) {
mainPanelItems.push(deleteAction.getAction(selectedCases));
}
@ -113,7 +125,16 @@ export const useBulkActions = ({
}
return panelsToBuild;
}, [canDelete, canUpdate, deleteAction, isDisabled, selectedCases, severityAction, statusAction]);
}, [
canDelete,
canUpdate,
deleteAction,
isDisabled,
selectedCases,
severityAction,
statusAction,
tagsAction,
]);
return {
modals: (
@ -125,6 +146,13 @@ export const useBulkActions = ({
onConfirm={deleteAction.onConfirmDeletion}
/>
) : null}
{tagsAction.isFlyoutOpen ? (
<EditTagsFlyout
onClose={tagsAction.onFlyoutClosed}
selectedCases={selectedCases}
onSaveTags={tagsAction.onSaveTags}
/>
) : null}
</>
),
panels,

View file

@ -12,6 +12,7 @@ import {
deleteAllCaseItems,
createComment,
updateCase,
getCase,
} from '../../../cases_api_integration/common/lib/utils';
import {
loginUsers,
@ -22,6 +23,8 @@ import { User } from '../../../cases_api_integration/common/lib/authentication/t
import { FtrProviderContext } from '../../ftr_provider_context';
import { generateRandomCaseWithoutConnector } from './helpers';
type OmitSupertest<T> = Omit<T, 'supertest'>;
export function CasesAPIServiceProvider({ getService }: FtrProviderContext) {
const kbnSupertest = getService('supertest');
const es = getService('es');
@ -94,5 +97,9 @@ export function CasesAPIServiceProvider({ getService }: FtrProviderContext) {
async suggestUserProfiles(options: Parameters<typeof suggestUserProfiles>[0]['req']) {
return suggestUserProfiles({ supertest: kbnSupertest, req: options });
},
async getCase({ caseId }: OmitSupertest<Parameters<typeof getCase>[0]>): Promise<CaseResponse> {
return getCase({ supertest: kbnSupertest, caseId });
},
};
}

View file

@ -180,13 +180,17 @@ export function CasesTableServiceProvider(
await common.clickAndValidate('options-filter-popover-button-assignees', 'euiSelectableList');
},
async selectAllCasesAndOpenBulkActions() {
await testSubjects.setCheckbox('checkboxSelectAll', 'check');
async openBulkActions() {
await testSubjects.existOrFail('case-table-bulk-actions-link-icon');
const button = await testSubjects.find('case-table-bulk-actions-link-icon');
await button.click();
},
async selectAllCasesAndOpenBulkActions() {
await testSubjects.setCheckbox('checkboxSelectAll', 'check');
await this.openBulkActions();
},
async changeStatus(status: CaseStatuses, index: number) {
await this.openRowActions(index);
@ -235,6 +239,57 @@ export function CasesTableServiceProvider(
await testSubjects.click(`cases-bulk-action-severity-${severity}`);
},
async bulkEditTags(selectedCases: number[], tagsToClick: string[]) {
const rows = await find.allByCssSelector('.euiTableRowCellCheckbox');
for (const caseIndex of selectedCases) {
assertCaseExists(caseIndex, rows.length);
rows[caseIndex].click();
}
await this.openBulkActions();
await testSubjects.existOrFail('cases-bulk-action-tags');
await testSubjects.click('cases-bulk-action-tags');
await testSubjects.existOrFail('cases-edit-tags-flyout');
for (const tag of tagsToClick) {
await testSubjects.existOrFail(`cases-actions-tags-edit-selectable-tag-${tag}`);
await testSubjects.click(`cases-actions-tags-edit-selectable-tag-${tag}`);
}
await testSubjects.click('cases-edit-tags-flyout-submit');
await testSubjects.missingOrFail('cases-edit-tags-flyout');
},
async bulkAddNewTag(selectedCases: number[], tag: string) {
const rows = await find.allByCssSelector('.euiTableRowCellCheckbox');
for (const caseIndex of selectedCases) {
assertCaseExists(caseIndex, rows.length);
rows[caseIndex].click();
}
await this.openBulkActions();
await testSubjects.existOrFail('cases-bulk-action-tags');
await testSubjects.click('cases-bulk-action-tags');
await testSubjects.existOrFail('cases-edit-tags-flyout');
await testSubjects.existOrFail('cases-actions-tags-edit-selectable-search-input');
const searchInput = await testSubjects.find(
'cases-actions-tags-edit-selectable-search-input'
);
await testSubjects.existOrFail('cases-actions-tags-edit-selectable-search-input');
await searchInput.type(tag);
await testSubjects.existOrFail('cases-actions-tags-edit-selectable-add-new-tag');
await testSubjects.click('cases-actions-tags-edit-selectable-add-new-tag');
await testSubjects.click('cases-edit-tags-flyout-submit');
await testSubjects.missingOrFail('cases-edit-tags-flyout');
},
async selectAndChangeStatusOfAllCases(status: CaseStatuses) {
await header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('cases-table', { timeout: 20 * 1000 });

View file

@ -113,6 +113,63 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await testSubjects.missingOrFail('case-table-column-severity-low');
});
});
describe('tags', () => {
let caseIds: string[] = [];
beforeEach(async () => {
caseIds = [];
const case1 = await cases.api.createCase({ title: 'case 1', tags: ['one', 'three'] });
const case2 = await cases.api.createCase({ title: 'case 2', tags: ['two', 'four'] });
const case3 = await cases.api.createCase({ title: 'case 3', tags: ['two', 'five'] });
caseIds.push(case1.id);
caseIds.push(case2.id);
caseIds.push(case3.id);
await header.waitUntilLoadingHasFinished();
await cases.casesTable.waitForCasesToBeListed();
});
afterEach(async () => {
await cases.api.deleteAllCases();
await cases.casesTable.waitForCasesToBeDeleted();
});
it('bulk edit tags', async () => {
/**
* Case 3 tags: two, five
* Case 2 tags: two, four
* Case 1 tags: one, three
* All tags: one, two, three, four, five.
*
* It selects Case 3 and Case 2 because the table orders
* the cases in descending order by creation date and clicks
* the one, three, and five tags
*/
await cases.casesTable.bulkEditTags([0, 1], ['two', 'three', 'five']);
await header.waitUntilLoadingHasFinished();
const case1 = await cases.api.getCase({ caseId: caseIds[0] });
const case2 = await cases.api.getCase({ caseId: caseIds[1] });
const case3 = await cases.api.getCase({ caseId: caseIds[2] });
expect(case3.tags).eql(['five', 'three']);
expect(case2.tags).eql(['four', 'five', 'three']);
expect(case1.tags).eql(['one', 'three']);
});
it('adds a new tag', async () => {
await cases.casesTable.bulkAddNewTag([0, 1], 'tw');
await header.waitUntilLoadingHasFinished();
const case1 = await cases.api.getCase({ caseId: caseIds[0] });
const case2 = await cases.api.getCase({ caseId: caseIds[1] });
const case3 = await cases.api.getCase({ caseId: caseIds[2] });
expect(case3.tags).eql(['two', 'five', 'tw']);
expect(case2.tags).eql(['two', 'four', 'tw']);
expect(case1.tags).eql(['one', 'three']);
});
});
});
describe('filtering', () => {