Add tag bulk action context menu (#82816)

* add the delete tag bulk action

* add unit tests for bulk delete

* fix duplicate i18n key

* add RBAC test on bulk delete

* add functional tests

* self review

* design nits

* add maxWidth option for confirm modal and add missing doc

* change bulk delete confirm modal max width

* add more missing doc

* only show loading state when performing the bulk delete

* use spacer instead of custom margin on horizontal rule

* use link instead of button to remove custom styles

* remove spacers, just use styles

* add divider when action menu is displayed

* set max-width for single delete confirm

* a11y fixes

* address nits

* add aria-label to delete action

Co-authored-by: Michail Yasonik <michail.yasonik@elastic.co>
This commit is contained in:
Pierre Gayvallet 2020-11-18 10:27:11 +01:00 committed by GitHub
parent 27125bce30
commit a7e5f07412
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 1419 additions and 51 deletions

View file

@ -82,6 +82,11 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [NotificationsSetup](./kibana-plugin-core-public.notificationssetup.md) | |
| [NotificationsStart](./kibana-plugin-core-public.notificationsstart.md) | |
| [OverlayBannersStart](./kibana-plugin-core-public.overlaybannersstart.md) | |
| [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) | |
| [OverlayFlyoutStart](./kibana-plugin-core-public.overlayflyoutstart.md) | APIs to open and manage fly-out dialogs. |
| [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) | |
| [OverlayModalOpenOptions](./kibana-plugin-core-public.overlaymodalopenoptions.md) | |
| [OverlayModalStart](./kibana-plugin-core-public.overlaymodalstart.md) | APIs to open and manage modal dialogs. |
| [OverlayRef](./kibana-plugin-core-public.overlayref.md) | Returned by [OverlayStart](./kibana-plugin-core-public.overlaystart.md) methods for closing a mounted overlay. |
| [OverlayStart](./kibana-plugin-core-public.overlaystart.md) | |
| [Plugin](./kibana-plugin-core-public.plugin.md) | The interface that should be returned by a <code>PluginInitializer</code>. |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) &gt; ["data-test-subj"](./kibana-plugin-core-public.overlayflyoutopenoptions._data-test-subj_.md)
## OverlayFlyoutOpenOptions."data-test-subj" property
<b>Signature:</b>
```typescript
'data-test-subj'?: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) &gt; [className](./kibana-plugin-core-public.overlayflyoutopenoptions.classname.md)
## OverlayFlyoutOpenOptions.className property
<b>Signature:</b>
```typescript
className?: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) &gt; [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md)
## OverlayFlyoutOpenOptions.closeButtonAriaLabel property
<b>Signature:</b>
```typescript
closeButtonAriaLabel?: string;
```

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md)
## OverlayFlyoutOpenOptions interface
<b>Signature:</b>
```typescript
export interface OverlayFlyoutOpenOptions
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| ["data-test-subj"](./kibana-plugin-core-public.overlayflyoutopenoptions._data-test-subj_.md) | <code>string</code> | |
| [className](./kibana-plugin-core-public.overlayflyoutopenoptions.classname.md) | <code>string</code> | |
| [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) | <code>string</code> | |
| [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md) | <code>boolean</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) &gt; [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md)
## OverlayFlyoutOpenOptions.ownFocus property
<b>Signature:</b>
```typescript
ownFocus?: boolean;
```

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayFlyoutStart](./kibana-plugin-core-public.overlayflyoutstart.md)
## OverlayFlyoutStart interface
APIs to open and manage fly-out dialogs.
<b>Signature:</b>
```typescript
export interface OverlayFlyoutStart
```
## Methods
| Method | Description |
| --- | --- |
| [open(mount, options)](./kibana-plugin-core-public.overlayflyoutstart.open.md) | Opens a flyout panel with the given mount point inside. You can use <code>close()</code> on the returned FlyoutRef to close the flyout. |

View file

@ -0,0 +1,25 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayFlyoutStart](./kibana-plugin-core-public.overlayflyoutstart.md) &gt; [open](./kibana-plugin-core-public.overlayflyoutstart.open.md)
## OverlayFlyoutStart.open() method
Opens a flyout panel with the given mount point inside. You can use `close()` on the returned FlyoutRef to close the flyout.
<b>Signature:</b>
```typescript
open(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| mount | <code>MountPoint</code> | |
| options | <code>OverlayFlyoutOpenOptions</code> | |
<b>Returns:</b>
`OverlayRef`

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) &gt; ["data-test-subj"](./kibana-plugin-core-public.overlaymodalconfirmoptions._data-test-subj_.md)
## OverlayModalConfirmOptions."data-test-subj" property
<b>Signature:</b>
```typescript
'data-test-subj'?: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) &gt; [buttonColor](./kibana-plugin-core-public.overlaymodalconfirmoptions.buttoncolor.md)
## OverlayModalConfirmOptions.buttonColor property
<b>Signature:</b>
```typescript
buttonColor?: EuiConfirmModalProps['buttonColor'];
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) &gt; [cancelButtonText](./kibana-plugin-core-public.overlaymodalconfirmoptions.cancelbuttontext.md)
## OverlayModalConfirmOptions.cancelButtonText property
<b>Signature:</b>
```typescript
cancelButtonText?: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) &gt; [className](./kibana-plugin-core-public.overlaymodalconfirmoptions.classname.md)
## OverlayModalConfirmOptions.className property
<b>Signature:</b>
```typescript
className?: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) &gt; [closeButtonAriaLabel](./kibana-plugin-core-public.overlaymodalconfirmoptions.closebuttonarialabel.md)
## OverlayModalConfirmOptions.closeButtonAriaLabel property
<b>Signature:</b>
```typescript
closeButtonAriaLabel?: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) &gt; [confirmButtonText](./kibana-plugin-core-public.overlaymodalconfirmoptions.confirmbuttontext.md)
## OverlayModalConfirmOptions.confirmButtonText property
<b>Signature:</b>
```typescript
confirmButtonText?: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) &gt; [defaultFocusedButton](./kibana-plugin-core-public.overlaymodalconfirmoptions.defaultfocusedbutton.md)
## OverlayModalConfirmOptions.defaultFocusedButton property
<b>Signature:</b>
```typescript
defaultFocusedButton?: EuiConfirmModalProps['defaultFocusedButton'];
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) &gt; [maxWidth](./kibana-plugin-core-public.overlaymodalconfirmoptions.maxwidth.md)
## OverlayModalConfirmOptions.maxWidth property
Sets the max-width of the modal. Set to `true` to use the default (`euiBreakpoints 'm'`<!-- -->), set to `false` to not restrict the width, set to a number for a custom width in px, set to a string for a custom width in custom measurement.
<b>Signature:</b>
```typescript
maxWidth?: boolean | number | string;
```

View file

@ -0,0 +1,27 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md)
## OverlayModalConfirmOptions interface
<b>Signature:</b>
```typescript
export interface OverlayModalConfirmOptions
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| ["data-test-subj"](./kibana-plugin-core-public.overlaymodalconfirmoptions._data-test-subj_.md) | <code>string</code> | |
| [buttonColor](./kibana-plugin-core-public.overlaymodalconfirmoptions.buttoncolor.md) | <code>EuiConfirmModalProps['buttonColor']</code> | |
| [cancelButtonText](./kibana-plugin-core-public.overlaymodalconfirmoptions.cancelbuttontext.md) | <code>string</code> | |
| [className](./kibana-plugin-core-public.overlaymodalconfirmoptions.classname.md) | <code>string</code> | |
| [closeButtonAriaLabel](./kibana-plugin-core-public.overlaymodalconfirmoptions.closebuttonarialabel.md) | <code>string</code> | |
| [confirmButtonText](./kibana-plugin-core-public.overlaymodalconfirmoptions.confirmbuttontext.md) | <code>string</code> | |
| [defaultFocusedButton](./kibana-plugin-core-public.overlaymodalconfirmoptions.defaultfocusedbutton.md) | <code>EuiConfirmModalProps['defaultFocusedButton']</code> | |
| [maxWidth](./kibana-plugin-core-public.overlaymodalconfirmoptions.maxwidth.md) | <code>boolean &#124; number &#124; string</code> | Sets the max-width of the modal. Set to <code>true</code> to use the default (<code>euiBreakpoints 'm'</code>), set to <code>false</code> to not restrict the width, set to a number for a custom width in px, set to a string for a custom width in custom measurement. |
| [title](./kibana-plugin-core-public.overlaymodalconfirmoptions.title.md) | <code>string</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalConfirmOptions](./kibana-plugin-core-public.overlaymodalconfirmoptions.md) &gt; [title](./kibana-plugin-core-public.overlaymodalconfirmoptions.title.md)
## OverlayModalConfirmOptions.title property
<b>Signature:</b>
```typescript
title?: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalOpenOptions](./kibana-plugin-core-public.overlaymodalopenoptions.md) &gt; ["data-test-subj"](./kibana-plugin-core-public.overlaymodalopenoptions._data-test-subj_.md)
## OverlayModalOpenOptions."data-test-subj" property
<b>Signature:</b>
```typescript
'data-test-subj'?: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalOpenOptions](./kibana-plugin-core-public.overlaymodalopenoptions.md) &gt; [className](./kibana-plugin-core-public.overlaymodalopenoptions.classname.md)
## OverlayModalOpenOptions.className property
<b>Signature:</b>
```typescript
className?: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalOpenOptions](./kibana-plugin-core-public.overlaymodalopenoptions.md) &gt; [closeButtonAriaLabel](./kibana-plugin-core-public.overlaymodalopenoptions.closebuttonarialabel.md)
## OverlayModalOpenOptions.closeButtonAriaLabel property
<b>Signature:</b>
```typescript
closeButtonAriaLabel?: string;
```

View file

@ -0,0 +1,21 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalOpenOptions](./kibana-plugin-core-public.overlaymodalopenoptions.md)
## OverlayModalOpenOptions interface
<b>Signature:</b>
```typescript
export interface OverlayModalOpenOptions
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| ["data-test-subj"](./kibana-plugin-core-public.overlaymodalopenoptions._data-test-subj_.md) | <code>string</code> | |
| [className](./kibana-plugin-core-public.overlaymodalopenoptions.classname.md) | <code>string</code> | |
| [closeButtonAriaLabel](./kibana-plugin-core-public.overlaymodalopenoptions.closebuttonarialabel.md) | <code>string</code> | |

View file

@ -0,0 +1,21 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalStart](./kibana-plugin-core-public.overlaymodalstart.md)
## OverlayModalStart interface
APIs to open and manage modal dialogs.
<b>Signature:</b>
```typescript
export interface OverlayModalStart
```
## Methods
| Method | Description |
| --- | --- |
| [open(mount, options)](./kibana-plugin-core-public.overlaymodalstart.open.md) | Opens a modal panel with the given mount point inside. You can use <code>close()</code> on the returned OverlayRef to close the modal. |
| [openConfirm(message, options)](./kibana-plugin-core-public.overlaymodalstart.openconfirm.md) | Opens a confirmation modal with the given text or mountpoint as a message. Returns a Promise resolving to <code>true</code> if user confirmed or <code>false</code> otherwise. |

View file

@ -0,0 +1,25 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalStart](./kibana-plugin-core-public.overlaymodalstart.md) &gt; [open](./kibana-plugin-core-public.overlaymodalstart.open.md)
## OverlayModalStart.open() method
Opens a modal panel with the given mount point inside. You can use `close()` on the returned OverlayRef to close the modal.
<b>Signature:</b>
```typescript
open(mount: MountPoint, options?: OverlayModalOpenOptions): OverlayRef;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| mount | <code>MountPoint</code> | |
| options | <code>OverlayModalOpenOptions</code> | |
<b>Returns:</b>
`OverlayRef`

View file

@ -0,0 +1,25 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayModalStart](./kibana-plugin-core-public.overlaymodalstart.md) &gt; [openConfirm](./kibana-plugin-core-public.overlaymodalstart.openconfirm.md)
## OverlayModalStart.openConfirm() method
Opens a confirmation modal with the given text or mountpoint as a message. Returns a Promise resolving to `true` if user confirmed or `false` otherwise.
<b>Signature:</b>
```typescript
openConfirm(message: MountPoint | string, options?: OverlayModalConfirmOptions): Promise<boolean>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| message | <code>MountPoint &#124; string</code> | |
| options | <code>OverlayModalConfirmOptions</code> | |
<b>Returns:</b>
`Promise<boolean>`

View file

@ -167,7 +167,16 @@ export {
IHttpResponseInterceptorOverrides,
} from './http';
export { OverlayStart, OverlayBannersStart, OverlayRef } from './overlays';
export {
OverlayStart,
OverlayBannersStart,
OverlayRef,
OverlayFlyoutStart,
OverlayFlyoutOpenOptions,
OverlayModalOpenOptions,
OverlayModalConfirmOptions,
OverlayModalStart,
} from './overlays';
export {
Toast,

View file

@ -20,5 +20,5 @@
export { OverlayRef } from './types';
export { OverlayBannersStart } from './banners';
export { OverlayFlyoutStart, OverlayFlyoutOpenOptions } from './flyout';
export { OverlayModalStart, OverlayModalOpenOptions } from './modal';
export { OverlayModalStart, OverlayModalOpenOptions, OverlayModalConfirmOptions } from './modal';
export { OverlayService, OverlayStart } from './overlay_service';

View file

@ -17,4 +17,9 @@
* under the License.
*/
export { ModalService, OverlayModalStart, OverlayModalOpenOptions } from './modal_service';
export {
ModalService,
OverlayModalStart,
OverlayModalOpenOptions,
OverlayModalConfirmOptions,
} from './modal_service';

View file

@ -70,6 +70,14 @@ export interface OverlayModalConfirmOptions {
'data-test-subj'?: string;
defaultFocusedButton?: EuiConfirmModalProps['defaultFocusedButton'];
buttonColor?: EuiConfirmModalProps['buttonColor'];
/**
* Sets the max-width of the modal.
* Set to `true` to use the default (`euiBreakpoints 'm'`),
* set to `false` to not restrict the width,
* set to a number for a custom width in px,
* set to a string for a custom width in custom measurement.
*/
maxWidth?: boolean | number | string;
}
/**

View file

@ -862,6 +862,60 @@ export interface OverlayBannersStart {
replace(id: string | undefined, mount: MountPoint, priority?: number): string;
}
// @public (undocumented)
export interface OverlayFlyoutOpenOptions {
// (undocumented)
'data-test-subj'?: string;
// (undocumented)
className?: string;
// (undocumented)
closeButtonAriaLabel?: string;
// (undocumented)
ownFocus?: boolean;
}
// @public
export interface OverlayFlyoutStart {
open(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef;
}
// @public (undocumented)
export interface OverlayModalConfirmOptions {
// (undocumented)
'data-test-subj'?: string;
// (undocumented)
buttonColor?: EuiConfirmModalProps['buttonColor'];
// (undocumented)
cancelButtonText?: string;
// (undocumented)
className?: string;
// (undocumented)
closeButtonAriaLabel?: string;
// (undocumented)
confirmButtonText?: string;
// (undocumented)
defaultFocusedButton?: EuiConfirmModalProps['defaultFocusedButton'];
maxWidth?: boolean | number | string;
// (undocumented)
title?: string;
}
// @public (undocumented)
export interface OverlayModalOpenOptions {
// (undocumented)
'data-test-subj'?: string;
// (undocumented)
className?: string;
// (undocumented)
closeButtonAriaLabel?: string;
}
// @public
export interface OverlayModalStart {
open(mount: MountPoint, options?: OverlayModalOpenOptions): OverlayRef;
openConfirm(message: MountPoint | string, options?: OverlayModalConfirmOptions): Promise<boolean>;
}
// @public
export interface OverlayRef {
close(): Promise<void>;
@ -874,12 +928,8 @@ export interface OverlayStart {
banners: OverlayBannersStart;
// (undocumented)
openConfirm: OverlayModalStart['openConfirm'];
// Warning: (ae-forgotten-export) The symbol "OverlayFlyoutStart" needs to be exported by the entry point index.d.ts
//
// (undocumented)
openFlyout: OverlayFlyoutStart['open'];
// Warning: (ae-forgotten-export) The symbol "OverlayModalStart" needs to be exported by the entry point index.d.ts
//
// (undocumented)
openModal: OverlayModalStart['open'];
}

View file

@ -6,6 +6,7 @@
import { SavedObject, SavedObjectReference } from 'src/core/types';
import { Tag, TagAttributes } from '../types';
import { TagsCapabilities } from '../capabilities';
export const createTagReference = (id: string): SavedObjectReference => ({
type: 'tag',
@ -35,3 +36,13 @@ export const createTagAttributes = (parts: Partial<TagAttributes> = {}): TagAttr
color: '#FF00CC',
...parts,
});
export const createTagCapabilities = (parts: Partial<TagsCapabilities> = {}): TagsCapabilities => ({
view: true,
create: true,
edit: true,
delete: true,
assign: true,
viewConnections: true,
...parts,
});

View file

@ -22,6 +22,7 @@ import {
EuiTextArea,
EuiSpacer,
EuiText,
htmlIdGenerator,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
@ -52,6 +53,7 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
tag,
mode,
}) => {
const optionalMessageId = htmlIdGenerator()();
const ifMounted = useIfMounted();
const [submitting, setSubmitting] = useState<boolean>(false);
@ -139,6 +141,12 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
onClick={() => setColor(getRandomColor())}
size="xs"
style={{ height: '18px', fontSize: '0.75rem' }}
aria-label={i18n.translate(
'xpack.savedObjectsTagging.management.createModal.color.randomizeAriaLabel',
{
defaultMessage: 'Randomize tag color',
}
)}
>
<FormattedMessage
id="xpack.savedObjectsTagging.management.createModal.color.randomize"
@ -165,7 +173,7 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
defaultMessage: 'Description',
})}
labelAppend={
<EuiText size="xs" color="subdued">
<EuiText size="xs" color="subdued" id={optionalMessageId}>
<FormattedMessage
id="xpack.savedObjectsTagging.management.optionalFieldText"
defaultMessage="Optional"
@ -184,6 +192,7 @@ export const CreateOrEditModal: FC<CreateOrEditModalProps> = ({
resize="none"
fullWidth={true}
compressed={true}
aria-describedby={optionalMessageId}
/>
</EuiFormRow>
</EuiForm>

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
overlayServiceMock,
notificationServiceMock,
} from '../../../../../../src/core/public/mocks';
import { tagClientMock } from '../../tags/tags_client.mock';
import { TagBulkAction } from '../types';
import { getBulkDeleteAction } from './bulk_delete';
describe('bulkDeleteAction', () => {
let tagClient: ReturnType<typeof tagClientMock.create>;
let overlays: ReturnType<typeof overlayServiceMock.createStartContract>;
let notifications: ReturnType<typeof notificationServiceMock.createStartContract>;
let setLoading: jest.MockedFunction<(loading: boolean) => void>;
let action: TagBulkAction;
const tagIds = ['id-1', 'id-2', 'id-3'];
beforeEach(() => {
tagClient = tagClientMock.create();
overlays = overlayServiceMock.createStartContract();
notifications = notificationServiceMock.createStartContract();
setLoading = jest.fn();
action = getBulkDeleteAction({ tagClient, overlays, notifications, setLoading });
});
it('performs the operation if the confirmation is accepted', async () => {
overlays.openConfirm.mockResolvedValue(true);
await action.execute(tagIds);
expect(overlays.openConfirm).toHaveBeenCalledTimes(1);
expect(tagClient.bulkDelete).toHaveBeenCalledTimes(1);
expect(tagClient.bulkDelete).toHaveBeenCalledWith(tagIds);
expect(notifications.toasts.addSuccess).toHaveBeenCalledTimes(1);
});
it('does not perform the operation if the confirmation is rejected', async () => {
overlays.openConfirm.mockResolvedValue(false);
await action.execute(tagIds);
expect(overlays.openConfirm).toHaveBeenCalledTimes(1);
expect(tagClient.bulkDelete).not.toHaveBeenCalled();
expect(notifications.toasts.addSuccess).not.toHaveBeenCalled();
});
it('does not show notification if `client.bulkDelete` rejects ', async () => {
overlays.openConfirm.mockResolvedValue(true);
tagClient.bulkDelete.mockRejectedValue(new Error('error calling bulkDelete'));
await expect(action.execute(tagIds)).rejects.toMatchInlineSnapshot(
`[Error: error calling bulkDelete]`
);
expect(notifications.toasts.addSuccess).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { OverlayStart, NotificationsStart } from 'src/core/public';
import { i18n } from '@kbn/i18n';
import { ITagInternalClient } from '../../tags';
import { TagBulkAction } from '../types';
interface GetBulkDeleteActionOptions {
overlays: OverlayStart;
notifications: NotificationsStart;
tagClient: ITagInternalClient;
setLoading: (loading: boolean) => void;
}
export const getBulkDeleteAction = ({
overlays,
notifications,
tagClient,
setLoading,
}: GetBulkDeleteActionOptions): TagBulkAction => {
return {
id: 'delete',
label: i18n.translate('xpack.savedObjectsTagging.management.actions.bulkDelete.label', {
defaultMessage: 'Delete',
}),
'aria-label': i18n.translate(
'xpack.savedObjectsTagging.management.actions.bulkDelete.ariaLabel',
{
defaultMessage: 'Delete selected tags',
}
),
icon: 'trash',
refreshAfterExecute: true,
execute: async (tagIds) => {
const confirmed = await overlays.openConfirm(
i18n.translate('xpack.savedObjectsTagging.management.actions.bulkDelete.confirm.text', {
defaultMessage:
'By deleting {count, plural, one {this tag} other {these tags}}, you will no longer be able to assign {count, plural, one {it} other {them}} to saved objects. ' +
'{count, plural, one {This tag} other {These tags}} will be removed from any saved objects that currently use {count, plural, one {it} other {them}}. ' +
'Are you sure you wish to proceed?',
values: {
count: tagIds.length,
},
}),
{
title: i18n.translate(
'xpack.savedObjectsTagging.management.actions.bulkDelete.confirm.title',
{
defaultMessage: 'Delete {count, plural, one {1 tag} other {# tags}}',
values: {
count: tagIds.length,
},
}
),
confirmButtonText: i18n.translate(
'xpack.savedObjectsTagging.management.actions.bulkDelete.confirm.confirmButtonText',
{
defaultMessage: 'Delete {count, plural, one {tag} other {tags}}',
values: {
count: tagIds.length,
},
}
),
buttonColor: 'danger',
maxWidth: 560,
}
);
if (confirmed) {
setLoading(true);
await tagClient.bulkDelete(tagIds);
setLoading(false);
notifications.toasts.addSuccess({
title: i18n.translate(
'xpack.savedObjectsTagging.management.actions.bulkDelete.notification.successTitle',
{
defaultMessage: 'Deleted {count, plural, one {1 tag} other {# tags}}',
values: {
count: tagIds.length,
},
}
),
});
}
},
};
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { TagBulkAction } from '../types';
interface GetClearSelectionActionOptions {
clearSelection: () => void;
}
export const getClearSelectionAction = ({
clearSelection,
}: GetClearSelectionActionOptions): TagBulkAction => {
return {
id: 'clear_selection',
label: i18n.translate('xpack.savedObjectsTagging.management.actions.clearSelection.label', {
defaultMessage: 'Clear selection',
}),
icon: 'cross',
refreshAfterExecute: true,
execute: async () => {
clearSelection();
},
};
};

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { coreMock } from '../../../../../../src/core/public/mocks';
import { createTagCapabilities } from '../../../common/test_utils';
import { TagsCapabilities } from '../../../common/capabilities';
import { tagClientMock } from '../../tags/tags_client.mock';
import { TagBulkAction } from '../types';
import { getBulkActions } from './index';
describe('getBulkActions', () => {
let core: ReturnType<typeof coreMock.createStart>;
let tagClient: ReturnType<typeof tagClientMock.create>;
let clearSelection: jest.MockedFunction<() => void>;
let setLoading: jest.MockedFunction<(loading: boolean) => void>;
beforeEach(() => {
core = coreMock.createStart();
tagClient = tagClientMock.create();
clearSelection = jest.fn();
setLoading = jest.fn();
});
const getActions = (caps: Partial<TagsCapabilities>) =>
getBulkActions({
core,
tagClient,
clearSelection,
setLoading,
capabilities: createTagCapabilities(caps),
});
const getIds = (actions: TagBulkAction[]) => actions.map((action) => action.id);
it('only returns the `delete` action if user got `delete` permission', () => {
let actions = getActions({ delete: true });
expect(getIds(actions)).toContain('delete');
actions = getActions({ delete: false });
expect(getIds(actions)).not.toContain('delete');
});
});

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreStart } from 'src/core/public';
import { TagsCapabilities } from '../../../common';
import { ITagInternalClient } from '../../tags';
import { TagBulkAction } from '../types';
import { getBulkDeleteAction } from './bulk_delete';
import { getClearSelectionAction } from './clear_selection';
interface GetBulkActionOptions {
core: CoreStart;
capabilities: TagsCapabilities;
tagClient: ITagInternalClient;
clearSelection: () => void;
setLoading: (loading: boolean) => void;
}
export const getBulkActions = ({
core: { notifications, overlays },
capabilities,
tagClient,
clearSelection,
setLoading,
}: GetBulkActionOptions): TagBulkAction[] => {
const actions: TagBulkAction[] = [];
if (capabilities.delete) {
actions.push(getBulkDeleteAction({ notifications, overlays, tagClient, setLoading }));
}
// only add clear selection if user has permission to perform any other action
// as having at least one action will show the bulk action menu, and the selection column on the table
// and we want to avoid doing that only for the 'unselect' action.
if (actions.length > 0) {
actions.push(getClearSelectionAction({ clearSelection }));
}
return actions;
};

View file

@ -0,0 +1,17 @@
.tagMgt__actionBar + .euiSpacer {
display: none;
}
.tagMgt__actionBarDivider {
height: $euiSize;
border-right: $euiBorderThin;
}
.tagMgt__actionBar {
border-bottom: $euiBorderThin;
padding-bottom: $euiSizeS;
}
.tagMgt__actionBarIcon {
margin-left: $euiSizeXS;
}

View file

@ -0,0 +1,122 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useCallback, useMemo, FC } from 'react';
import {
EuiPopover,
EuiFlexItem,
EuiFlexGroup,
EuiContextMenu,
EuiContextMenuPanelItemDescriptor,
EuiText,
EuiLink,
EuiIcon,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { TagBulkAction } from '../types';
import './_action_bar.scss';
export interface ActionBarProps {
actions: TagBulkAction[];
totalCount: number;
selectedCount: number;
onActionSelected: (action: TagBulkAction) => void;
}
const actionToMenuItem = (
action: TagBulkAction,
onActionSelected: (action: TagBulkAction) => void,
closePopover: () => void
): EuiContextMenuPanelItemDescriptor => {
return {
name: action.label,
icon: action.icon,
onClick: () => {
closePopover();
onActionSelected(action);
},
'data-test-subj': `actionBar-button-${action.id}`,
};
};
export const ActionBar: FC<ActionBarProps> = ({
actions,
onActionSelected,
selectedCount,
totalCount,
}) => {
const [isPopoverOpened, setPopOverOpened] = useState(false);
const closePopover = useCallback(() => {
setPopOverOpened(false);
}, [setPopOverOpened]);
const togglePopover = useCallback(() => {
setPopOverOpened((opened) => !opened);
}, [setPopOverOpened]);
const contextMenuPanels = useMemo(() => {
return [
{
id: 0,
items: actions.map((action) => actionToMenuItem(action, onActionSelected, closePopover)),
},
];
}, [actions, onActionSelected, closePopover]);
return (
<div className="tagMgt__actionBar">
<EuiFlexGroup justifyContent="flexStart" alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.savedObjectsTagging.management.actionBar.totalTagsLabel"
defaultMessage="{count, plural, one {1 tag} other {# tags}}"
values={{
count: totalCount,
}}
/>
</EuiText>
</EuiFlexItem>
{selectedCount > 0 && (
<>
<EuiFlexItem grow={false}>
<div className="tagMgt__actionBarDivider" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
isOpen={isPopoverOpened}
closePopover={closePopover}
panelPaddingSize="none"
button={
<EuiText size="xs">
<EuiLink onClick={togglePopover} data-test-subj="actionBar-contextMenuButton">
<FormattedMessage
id="xpack.savedObjectsTagging.management.actionBar.selectedTagsLabel"
defaultMessage="{count, plural, one {1 selected tag} other {# selected tags}}"
values={{
count: selectedCount,
}}
/>
<EuiIcon className="tagMgt__actionBarIcon" type="arrowDown" size="s" />
</EuiLink>
</EuiText>
}
>
<EuiContextMenu
initialPanelId={0}
panels={contextMenuPanels}
data-test-subj="actionBar-contextMenu"
/>
</EuiPopover>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
</div>
);
};

View file

@ -6,3 +6,4 @@
export { Header } from './header';
export { TagTable } from './table';
export { ActionBar } from './action_bar';

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useRef, useEffect, FC } from 'react';
import { EuiInMemoryTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui';
import React, { useRef, useEffect, FC, ReactNode } from 'react';
import { EuiInMemoryTable, EuiBasicTableColumn, EuiLink, Query } from '@elastic/eui';
import { Action as EuiTableAction } from '@elastic/eui/src/components/basic_table/action_types';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
@ -16,12 +16,16 @@ interface TagTableProps {
loading: boolean;
capabilities: TagsCapabilities;
tags: TagWithRelations[];
initialQuery?: Query;
allowSelection: boolean;
onQueryChange: (query?: Query) => void;
selectedTags: TagWithRelations[];
onSelectionChange: (selection: TagWithRelations[]) => void;
onEdit: (tag: TagWithRelations) => void;
onDelete: (tag: TagWithRelations) => void;
getTagRelationUrl: (tag: TagWithRelations) => string;
onShowRelations: (tag: TagWithRelations) => void;
actionBar: ReactNode;
}
const tablePagination = {
@ -43,11 +47,16 @@ export const TagTable: FC<TagTableProps> = ({
loading,
capabilities,
tags,
initialQuery,
allowSelection,
onQueryChange,
selectedTags,
onSelectionChange,
onEdit,
onDelete,
onShowRelations,
getTagRelationUrl,
actionBar,
}) => {
const tableRef = useRef<EuiInMemoryTable<TagWithRelations>>(null);
@ -60,9 +69,11 @@ export const TagTable: FC<TagTableProps> = ({
const actions: Array<EuiTableAction<TagWithRelations>> = [];
if (capabilities.edit) {
actions.push({
name: i18n.translate('xpack.savedObjectsTagging.management.table.actions.edit.title', {
defaultMessage: 'Edit',
}),
name: ({ name }) =>
i18n.translate('xpack.savedObjectsTagging.management.table.actions.edit.title', {
defaultMessage: 'Edit {name} tag',
values: { name },
}),
description: i18n.translate(
'xpack.savedObjectsTagging.management.table.actions.edit.description',
{
@ -77,9 +88,11 @@ export const TagTable: FC<TagTableProps> = ({
}
if (capabilities.delete) {
actions.push({
name: i18n.translate('xpack.savedObjectsTagging.management.table.actions.delete.title', {
defaultMessage: 'Delete',
}),
name: ({ name }) =>
i18n.translate('xpack.savedObjectsTagging.management.table.actions.delete.title', {
defaultMessage: 'Delete {name} tag',
values: { name },
}),
description: i18n.translate(
'xpack.savedObjectsTagging.management.table.actions.delete.description',
{
@ -171,13 +184,30 @@ export const TagTable: FC<TagTableProps> = ({
<EuiInMemoryTable
data-test-subj="tagsManagementTable"
ref={tableRef}
childrenBetween={actionBar}
loading={loading}
itemId={'id'}
columns={columns}
items={tags}
pagination={tablePagination}
sorting={sorting}
tableCaption={i18n.translate('xpack.savedObjectsTagging.management.table.columns.caption', {
defaultMessage: 'Tags',
})}
rowHeader="name"
selection={
allowSelection
? {
initialSelected: selectedTags,
onSelectionChange,
}
: undefined
}
search={{
defaultQuery: initialQuery,
onChange: ({ query }) => {
onQueryChange(query || undefined);
},
box: {
'data-test-subj': 'tagsManagementSearchBar',
incremental: true,

View file

@ -6,13 +6,15 @@
import React, { useEffect, useCallback, useState, useMemo, FC } from 'react';
import useMount from 'react-use/lib/useMount';
import { EuiPageContent } from '@elastic/eui';
import { EuiPageContent, Query } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ChromeBreadcrumb, CoreStart } from 'src/core/public';
import { TagWithRelations, TagsCapabilities } from '../../common';
import { getCreateModalOpener, getEditModalOpener } from '../components/edition_modal';
import { ITagInternalClient } from '../tags';
import { Header, TagTable } from './components';
import { TagBulkAction } from './types';
import { Header, TagTable, ActionBar } from './components';
import { getBulkActions } from './actions';
import { getTagConnectionsUrl } from './utils';
interface TagManagementPageParams {
@ -32,6 +34,21 @@ export const TagManagementPage: FC<TagManagementPageParams> = ({
const [loading, setLoading] = useState<boolean>(false);
const [allTags, setAllTags] = useState<TagWithRelations[]>([]);
const [selectedTags, setSelectedTags] = useState<TagWithRelations[]>([]);
const [query, setQuery] = useState<Query | undefined>();
const filteredTags = useMemo(() => {
return query ? Query.execute(query, allTags) : allTags;
}, [allTags, query]);
const bulkActions = useMemo(() => {
return getBulkActions({
core,
capabilities,
tagClient,
setLoading,
clearSelection: () => setSelectedTags([]),
});
}, [core, capabilities, tagClient]);
const createModalOpener = useMemo(() => getCreateModalOpener({ overlays, tagClient }), [
overlays,
@ -140,13 +157,12 @@ export const TagManagementPage: FC<TagManagementPageParams> = ({
}
),
buttonColor: 'danger',
maxWidth: 560,
}
);
if (confirmed) {
await tagClient.delete(tag.id);
fetchTags();
notifications.toasts.addSuccess({
title: i18n.translate('xpack.savedObjectsTagging.notifications.deleteTagSuccessTitle', {
defaultMessage: 'Deleted "{name}" tag',
@ -155,18 +171,59 @@ export const TagManagementPage: FC<TagManagementPageParams> = ({
},
}),
});
await fetchTags();
}
},
[overlays, notifications, fetchTags, tagClient]
);
const executeBulkAction = useCallback(
async (action: TagBulkAction) => {
try {
await action.execute(selectedTags.map(({ id }) => id));
} catch (e) {
notifications.toasts.addError(e, {
title: i18n.translate('xpack.savedObjectsTagging.notifications.bulkActionError', {
defaultMessage: 'An error occurred',
}),
});
} finally {
setLoading(false);
}
if (action.refreshAfterExecute) {
await fetchTags();
}
},
[selectedTags, fetchTags, notifications]
);
const actionBar = useMemo(
() => (
<ActionBar
actions={bulkActions}
totalCount={filteredTags.length}
selectedCount={selectedTags.length}
onActionSelected={executeBulkAction}
/>
),
[selectedTags, filteredTags, bulkActions, executeBulkAction]
);
return (
<EuiPageContent horizontalPosition="center">
<Header canCreate={capabilities.create} onCreate={openCreateModal} />
<TagTable
loading={loading}
tags={allTags}
tags={filteredTags}
capabilities={capabilities}
actionBar={actionBar}
initialQuery={query}
onQueryChange={(newQuery) => {
setQuery(newQuery);
setSelectedTags([]);
}}
allowSelection={bulkActions.length > 0}
selectedTags={selectedTags}
onSelectionChange={(tags) => {
setSelectedTags(tags);

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiIconType } from '@elastic/eui/src/components/icon/icon';
/**
* Represents a tag `bulk action`
*/
export interface TagBulkAction {
/**
* The unique identifier for this action.
*/
id: string;
/**
* The label displayed in the bulk action context menu.
*/
label: string;
/**
* Optional aria-label if the visual label isn't descriptive enough.
*/
'aria-label'?: string;
/**
* An optional icon to display before the label in the context menu.
*/
icon?: EuiIconType;
/**
* Handler to execute this action against the given list of selected tag ids.
*/
execute: (tagIds: string[]) => void | Promise<void>;
/**
* If true, the list of tags will be reloaded after the action's execution. Defaults to false.
*/
refreshAfterExecute?: boolean;
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ITagInternalClient } from './tags_client';
const createInternalClientMock = () => {
const mock: jest.Mocked<ITagInternalClient> = {
create: jest.fn(),
get: jest.fn(),
getAll: jest.fn(),
delete: jest.fn(),
update: jest.fn(),
find: jest.fn(),
bulkDelete: jest.fn(),
};
return mock;
};
export const tagClientMock = {
create: createInternalClientMock,
};

View file

@ -216,41 +216,83 @@ describe('TagsClient', () => {
});
});
/////
describe('internal APIs', () => {
describe('#find', () => {
const findOptions: FindTagsOptions = {
search: 'for, you know.',
};
let expectedTags: Tag[];
describe('#find', () => {
const findOptions: FindTagsOptions = {
search: 'for, you know.',
};
let expectedTags: Tag[];
beforeEach(() => {
expectedTags = [
createTag({ id: 'tag-1' }),
createTag({ id: 'tag-2' }),
createTag({ id: 'tag-3' }),
];
http.get.mockResolvedValue({ tags: expectedTags, total: expectedTags.length });
});
beforeEach(() => {
expectedTags = [
createTag({ id: 'tag-1' }),
createTag({ id: 'tag-2' }),
createTag({ id: 'tag-3' }),
];
http.get.mockResolvedValue({ tags: expectedTags, total: expectedTags.length });
});
it('calls `http.get` with the correct parameters', async () => {
await tagsClient.find(findOptions);
it('calls `http.get` with the correct parameters', async () => {
await tagsClient.find(findOptions);
expect(http.get).toHaveBeenCalledTimes(1);
expect(http.get).toHaveBeenCalledWith(`/internal/saved_objects_tagging/tags/_find`, {
query: findOptions,
});
});
it('returns the tag objects from the response', async () => {
const { tags, total } = await tagsClient.find(findOptions);
expect(tags).toEqual(expectedTags);
expect(total).toEqual(3);
});
it('forwards the error from the http call if any', async () => {
const error = new Error('something when wrong');
http.get.mockRejectedValue(error);
expect(http.get).toHaveBeenCalledTimes(1);
expect(http.get).toHaveBeenCalledWith(`/internal/saved_objects_tagging/tags/_find`, {
query: findOptions,
await expect(tagsClient.find(findOptions)).rejects.toThrowError(error);
});
});
it('returns the tag objects from the response', async () => {
const { tags, total } = await tagsClient.find(findOptions);
expect(tags).toEqual(expectedTags);
expect(total).toEqual(3);
});
it('forwards the error from the http call if any', async () => {
const error = new Error('something when wrong');
http.get.mockRejectedValue(error);
await expect(tagsClient.find(findOptions)).rejects.toThrowError(error);
describe('#bulkDelete', () => {
const tagIds = ['id-to-delete-1', 'id-to-delete-2'];
beforeEach(() => {
http.post.mockResolvedValue({});
});
it('calls `http.post` with the correct parameters', async () => {
await tagsClient.bulkDelete(tagIds);
expect(http.post).toHaveBeenCalledTimes(1);
expect(http.post).toHaveBeenCalledWith(
`/internal/saved_objects_tagging/tags/_bulk_delete`,
{
body: JSON.stringify({
ids: tagIds,
}),
}
);
});
it('forwards the error from the http call if any', async () => {
const error = new Error('something when wrong');
http.post.mockRejectedValue(error);
await expect(tagsClient.bulkDelete(tagIds)).rejects.toThrowError(error);
});
it('notifies its changeListener if the http call succeed', async () => {
await tagsClient.bulkDelete(tagIds);
expect(changeListener.onDelete).toHaveBeenCalledTimes(2);
expect(changeListener.onDelete).toHaveBeenCalledWith(tagIds[0]);
expect(changeListener.onDelete).toHaveBeenCalledWith(tagIds[1]);
});
it('ignores potential errors when calling `changeListener.onDelete`', async () => {
changeListener.onDelete.mockImplementation(() => {
throw new Error('error in onCreate');
});
await expect(tagsClient.bulkDelete(tagIds)).resolves.toBeUndefined();
});
});
});
});

View file

@ -34,6 +34,7 @@ const trapErrors = (fn: () => void) => {
export interface ITagInternalClient extends ITagsClient {
find(options: FindTagsOptions): Promise<FindTagsResponse>;
bulkDelete(ids: string[]): Promise<void>;
}
export class TagsClient implements ITagInternalClient {
@ -114,4 +115,20 @@ export class TagsClient implements ITagInternalClient {
},
});
}
public async bulkDelete(tagIds: string[]) {
await this.http.post<{}>('/internal/saved_objects_tagging/tags/_bulk_delete', {
body: JSON.stringify({
ids: tagIds,
}),
});
trapErrors(() => {
if (this.changeListener) {
tagIds.forEach((tagId) => {
this.changeListener!.onDelete(tagId);
});
}
});
}
}

View file

@ -10,7 +10,7 @@ import { registerDeleteTagRoute } from './delete_tag';
import { registerGetAllTagsRoute } from './get_all_tags';
import { registerGetTagRoute } from './get_tag';
import { registerUpdateTagRoute } from './update_tag';
import { registerInternalFindTagsRoute } from './internal';
import { registerInternalFindTagsRoute, registerInternalBulkDeleteRoute } from './internal';
export const registerRoutes = ({ router }: { router: IRouter }) => {
// public API
@ -21,4 +21,5 @@ export const registerRoutes = ({ router }: { router: IRouter }) => {
registerGetTagRoute(router);
// internal API
registerInternalFindTagsRoute(router);
registerInternalBulkDeleteRoute(router);
};

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { IRouter } from 'src/core/server';
export const registerInternalBulkDeleteRoute = (router: IRouter) => {
router.post(
{
path: '/internal/saved_objects_tagging/tags/_bulk_delete',
validate: {
body: schema.object({
ids: schema.arrayOf(schema.string()),
}),
},
},
router.handleLegacyErrors(async (ctx, req, res) => {
const { ids: tagIds } = req.body;
const client = ctx.tags!.tagsClient;
for (const tagId of tagIds) {
await client.delete(tagId);
}
return res.ok({
body: {},
});
})
);
};

View file

@ -5,3 +5,4 @@
*/
export { registerInternalFindTagsRoute } from './find_tags';
export { registerInternalBulkDeleteRoute } from './bulk_delete';

View file

@ -156,6 +156,13 @@ export function TagManagementPageProvider({ getService, getPageObjects }: FtrPro
}
}
/**
* Tag management page object.
*
* @remarks All the table manipulation helpers makes the assumption
* that all tags are displayed on a single page. Pagination
* and finding / interacting with a tag on another page is not supported.
*/
class TagManagementPage {
public readonly tagModal = new TagModal(this);
@ -272,6 +279,90 @@ export function TagManagementPageProvider({ getService, getPageObjects }: FtrPro
await connectionLink.click();
}
/**
* Return true if the selection column is displayed on the table, false otherwise.
*/
async isSelectionColumnDisplayed() {
const firstRow = await testSubjects.find('tagsTableRow');
const checkbox = await firstRow.findAllByCssSelector(
'.euiTableRowCellCheckbox .euiCheckbox__input'
);
return Boolean(checkbox.length);
}
/**
* Click on the selection checkbox of the tag matching given tag name.
*/
async selectTagByName(tagName: string) {
const tagRow = await this.getRowByName(tagName);
const checkbox = await tagRow.findByCssSelector(
'.euiTableRowCellCheckbox .euiCheckbox__input'
);
await checkbox.click();
}
/**
* Returns true if the tag bulk action menu is displayed, false otherwise.
*/
async isActionMenuButtonDisplayed() {
return testSubjects.exists('actionBar-contextMenuButton');
}
/**
* Open the bulk action menu if not already opened.
*/
async openActionMenu() {
if (!(await this.isActionMenuOpened())) {
await this.toggleActionMenu();
}
}
/**
* Check if the action for given `actionId` is present in the bulk action menu.
*
* The menu will automatically be opened if not already, but the test must still
* select tags to make the action menu button appear.
*/
async isActionPresent(actionId: string) {
if (!(await this.isActionMenuButtonDisplayed())) {
return false;
}
const menuWasOpened = await this.isActionMenuOpened();
if (!menuWasOpened) {
await this.openActionMenu();
}
const actionExists = await testSubjects.exists(`actionBar-button-${actionId}`);
if (!menuWasOpened) {
await this.toggleActionMenu();
}
return actionExists;
}
/**
* Click on given bulk action button
*/
async clickOnAction(actionId: string) {
await this.openActionMenu();
await testSubjects.click(`actionBar-button-${actionId}`);
}
/**
* Toggle (close if opened, open if closed) the bulk action menu.
*/
async toggleActionMenu() {
await testSubjects.click('actionBar-contextMenuButton');
}
/**
* Return true if the bulk action menu is opened, false otherwise.
*/
async isActionMenuOpened() {
return testSubjects.exists('actionBar-contextMenuPopover');
}
/**
* Return the info of all the tags currently displayed in the table (in table's order)
*/

View file

@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { USERS, User, ExpectedResponse } from '../../../common/lib';
import { FtrProviderContext } from '../services';
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const supertest = getService('supertestWithoutAuth');
describe('POST /internal/saved_objects_tagging/tags/_bulk_delete', () => {
beforeEach(async () => {
await esArchiver.load('rbac_tags');
});
afterEach(async () => {
await esArchiver.unload('rbac_tags');
});
const responses: Record<string, ExpectedResponse> = {
authorized: {
httpCode: 200,
expectResponse: ({ body }) => {
expect(body).to.eql({});
},
},
unauthorized: {
httpCode: 403,
expectResponse: ({ body }) => {
expect(body).to.eql({
statusCode: 403,
error: 'Forbidden',
message: 'Unable to delete tag',
});
},
},
};
const expectedResults: Record<string, User[]> = {
authorized: [
USERS.SUPERUSER,
USERS.DEFAULT_SPACE_SO_MANAGEMENT_WRITE_USER,
USERS.DEFAULT_SPACE_SO_TAGGING_WRITE_USER,
],
unauthorized: [
USERS.DEFAULT_SPACE_READ_USER,
USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER,
USERS.DEFAULT_SPACE_DASHBOARD_READ_USER,
USERS.DEFAULT_SPACE_VISUALIZE_READ_USER,
USERS.DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER,
USERS.NOT_A_KIBANA_USER,
],
};
const createUserTest = (
{ username, password, description }: User,
{ httpCode, expectResponse }: ExpectedResponse
) => {
it(`returns expected ${httpCode} response for ${description ?? username}`, async () => {
await supertest
.post(`/internal/saved_objects_tagging/tags/_bulk_delete`)
.send({
ids: ['default-space-tag-1', 'default-space-tag-2'],
})
.auth(username, password)
.expect(httpCode)
.then(expectResponse);
});
};
const createTestSuite = () => {
Object.entries(expectedResults).forEach(([responseId, users]) => {
const response: ExpectedResponse = responses[responseId];
users.forEach((user) => {
createUserTest(user, response);
});
});
};
createTestSuite();
});
}

View file

@ -22,5 +22,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./update'));
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./_find'));
loadTestFile(require.resolve('./_bulk_delete'));
});
}

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ getPageObjects, getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const PageObjects = getPageObjects(['common', 'security', 'savedObjects', 'tagManagement']);
const tagManagementPage = PageObjects.tagManagement;
describe('table bulk actions', () => {
beforeEach(async () => {
await esArchiver.load('functional_base');
await tagManagementPage.navigateTo();
});
afterEach(async () => {
await esArchiver.unload('functional_base');
});
describe('bulk delete', () => {
it('deletes multiple tags', async () => {
await tagManagementPage.selectTagByName('tag-1');
await tagManagementPage.selectTagByName('tag-3');
await tagManagementPage.clickOnAction('delete');
await PageObjects.common.clickConfirmOnModal();
await tagManagementPage.waitUntilTableIsLoaded();
const displayedTags = await tagManagementPage.getDisplayedTagNames();
expect(displayedTags.length).to.be(3);
expect(displayedTags).to.eql(['my-favorite-tag', 'tag with whitespace', 'tag-2']);
});
});
describe('clear selection', () => {
it('clears the current selection', async () => {
await tagManagementPage.selectTagByName('tag-1');
await tagManagementPage.selectTagByName('tag-3');
await tagManagementPage.clickOnAction('clear_selection');
await tagManagementPage.waitUntilTableIsLoaded();
expect(await tagManagementPage.isActionMenuButtonDisplayed()).to.be(false);
});
});
});
}

View file

@ -35,6 +35,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
});
};
const selectSomeTags = async () => {
if (await tagManagementPage.isSelectionColumnDisplayed()) {
await tagManagementPage.selectTagByName('tag-1');
await tagManagementPage.selectTagByName('tag-3');
}
};
const addFeatureControlSuite = ({ user, description, privileges }: FeatureControlUserSuite) => {
const testPrefix = (allowed: boolean) => (allowed ? `can` : `can't`);
@ -57,6 +64,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
expect(await tagManagementPage.isDeleteButtonVisible()).to.be(privileges.delete);
});
it(`${testPrefix(privileges.delete)} bulk delete tags`, async () => {
await selectSomeTags();
expect(await tagManagementPage.isActionPresent('delete')).to.be(privileges.delete);
});
it(`${testPrefix(privileges.create)} create tag`, async () => {
expect(await tagManagementPage.isCreateButtonVisible()).to.be(privileges.create);
});

View file

@ -17,6 +17,7 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) {
});
loadTestFile(require.resolve('./listing'));
loadTestFile(require.resolve('./bulk_actions'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./edit'));
loadTestFile(require.resolve('./som_integration'));

View file

@ -102,7 +102,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
expect(itemNames).to.contain('My new markdown viz');
});
it('allows to assign tags to the new visualization', async () => {
it('allows to create a tag from the tag selector', async () => {
const { tagModal } = PageObjects.tagManagement;
await PageObjects.visualize.navigateToNewVisualization();