mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
b24bfb4f25
commit
c73dc349ae
14 changed files with 1495 additions and 4 deletions
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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 },
|
||||
});
|
11
x-pack/plugins/cases/public/components/actions/tags/types.ts
Normal file
11
x-pack/plugins/cases/public/components/actions/tags/types.ts
Normal 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[];
|
||||
}
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>;
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue