Skip to content

Commit

Permalink
Add bulk assign action to tag management (#84177)
Browse files Browse the repository at this point in the history
* initial draft

* move components to their own files

* create services folder and move tags package

* add assignment service

* fix some types

* prepare assign tag route

* move server-side tag client under the `services` folder

* add security check, move a lot of stuff.

* design improvements

* display tags in flyout

* improve button and add notification on save

* add action on tag rows

* fix types

* fix mock import paths

* add lens to the list of assignable types

* update generated doc

* add base functional tests

* move api to internal

* add api/security test suites

* add / use get_assignable_types API

* fix feature control tests

* fix assignable types propagation

* rename actions folder to bulk_actions

* extract actions to their own module

* add common / server unit tests

* add client-side assign tests

* add some tests and tsdoc

* typo

* add getActions test

* revert width change

* fix typo in API

* various minor improvements

* typo

* tsdoc on flyout page object

* close flyout when leaving the page

* fix bug when redirecting to SO management with a tag having whitespaces in its name

* check for dupes in toAdd and toRemove

* add lazy load to assign modal opener

* add lazy load to edit/create modals

* check if at least one assign or unassign tag id is specified

* grammar

* add explicit type existence check
  • Loading branch information
pgayvallet authored Dec 7, 2020
1 parent 34be1e7 commit 446390d
Show file tree
Hide file tree
Showing 137 changed files with 4,100 additions and 336 deletions.
Original file line number Diff line number Diff line change
@@ -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; [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md)

## OverlayFlyoutOpenOptions.maxWidth property

<b>Signature:</b>

```typescript
maxWidth?: boolean | number | string;
```
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@ export interface OverlayFlyoutOpenOptions
| ["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> | |
| [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md) | <code>boolean &#124; number &#124; string</code> | |
| [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md) | <code>boolean</code> | |
| [size](./kibana-plugin-core-public.overlayflyoutopenoptions.size.md) | <code>EuiFlyoutSize</code> | |

Original file line number Diff line number Diff line change
@@ -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; [size](./kibana-plugin-core-public.overlayflyoutopenoptions.size.md)

## OverlayFlyoutOpenOptions.size property

<b>Signature:</b>

```typescript
size?: EuiFlyoutSize;
```
4 changes: 3 additions & 1 deletion src/core/public/overlays/flyout/flyout_service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

/* eslint-disable max-classes-per-file */

import { EuiFlyout } from '@elastic/eui';
import { EuiFlyout, EuiFlyoutSize } from '@elastic/eui';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Subject } from 'rxjs';
Expand Down Expand Up @@ -93,6 +93,8 @@ export interface OverlayFlyoutOpenOptions {
closeButtonAriaLabel?: string;
ownFocus?: boolean;
'data-test-subj'?: string;
size?: EuiFlyoutSize;
maxWidth?: boolean | number | string;
}

interface StartDeps {
Expand Down
5 changes: 5 additions & 0 deletions src/core/public/public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { EnvironmentMode } from '@kbn/config';
import { EuiBreadcrumb } from '@elastic/eui';
import { EuiButtonEmptyProps } from '@elastic/eui';
import { EuiConfirmModalProps } from '@elastic/eui';
import { EuiFlyoutSize } from '@elastic/eui';
import { EuiGlobalToastListToast } from '@elastic/eui';
import { ExclusiveUnion } from '@elastic/eui';
import { History } from 'history';
Expand Down Expand Up @@ -885,7 +886,11 @@ export interface OverlayFlyoutOpenOptions {
// (undocumented)
closeButtonAriaLabel?: string;
// (undocumented)
maxWidth?: boolean | number | string;
// (undocumented)
ownFocus?: boolean;
// (undocumented)
size?: EuiFlyoutSize;
}

// @public
Expand Down
1 change: 1 addition & 0 deletions src/plugins/data/public/public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { EuiBreadcrumb } from '@elastic/eui';
import { EuiButtonEmptyProps } from '@elastic/eui';
import { EuiComboBoxProps } from '@elastic/eui';
import { EuiConfirmModalProps } from '@elastic/eui';
import { EuiFlyoutSize } from '@elastic/eui';
import { EuiGlobalToastListToast } from '@elastic/eui';
import { EventEmitter } from 'events';
import { ExclusiveUnion } from '@elastic/eui';
Expand Down
1 change: 1 addition & 0 deletions src/plugins/embeddable/public/public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { EuiButtonEmptyProps } from '@elastic/eui';
import { EuiComboBoxProps } from '@elastic/eui';
import { EuiConfirmModalProps } from '@elastic/eui';
import { EuiContextMenuPanelDescriptor } from '@elastic/eui';
import { EuiFlyoutSize } from '@elastic/eui';
import { EuiGlobalToastListToast } from '@elastic/eui';
import { EventEmitter } from 'events';
import { ExclusiveUnion } from '@elastic/eui';
Expand Down
5 changes: 5 additions & 0 deletions x-pack/plugins/saved_objects_tagging/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,8 @@ export const tagUsageCollectorSchema: MakeSchemaFrom<TaggingUsageData> = {
},
};
```

### Update the `taggableTypes` constant to add your type

Edit the `taggableTypes` list in `x-pack/plugins/saved_objects_tagging/common/constants.ts` to add
the name of the type you are adding.
39 changes: 39 additions & 0 deletions x-pack/plugins/saved_objects_tagging/common/assignments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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.
*/

/**
* `type`+`id` tuple of a saved object
*/
export interface ObjectReference {
type: string;
id: string;
}

/**
* Represent an assignable saved object, as returned by the `_find_assignable_objects` API
*/
export interface AssignableObject extends ObjectReference {
icon?: string;
title: string;
tags: string[];
}

export interface UpdateTagAssignmentsOptions {
tags: string[];
assign: ObjectReference[];
unassign: ObjectReference[];
}

export interface FindAssignableObjectsOptions {
search?: string;
maxResults?: number;
types?: string[];
}

/**
* Return a string that can be used as an unique identifier for given saved object
*/
export const getKey = ({ id, type }: ObjectReference) => `${type}|${id}`;
13 changes: 13 additions & 0 deletions x-pack/plugins/saved_objects_tagging/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/

/**
* The id of the tagging feature as registered to to `features` plugin
*/
export const tagFeatureId = 'savedObjectsTagging';
/**
* The saved object type for `tag` objects
*/
export const tagSavedObjectTypeName = 'tag';
/**
* The management section id as registered to the `management` plugin
*/
export const tagManagementSectionId = 'tags';
/**
* The list of saved object types that are currently supporting tagging.
*/
export const taggableTypes = ['dashboard', 'visualization', 'map', 'lens'];
15 changes: 15 additions & 0 deletions x-pack/plugins/saved_objects_tagging/common/http_api_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* 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 { AssignableObject } from './assignments';

export interface FindAssignableObjectResponse {
objects: AssignableObject[];
}

export interface GetAssignableTypesResponse {
types: string[];
}
127 changes: 127 additions & 0 deletions x-pack/plugins/saved_objects_tagging/common/references.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* 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 { SavedObjectReference } from 'src/core/types';
import { tagIdToReference, replaceTagReferences, updateTagReferences } from './references';

const ref = (type: string, id: string): SavedObjectReference => ({
id,
type,
name: `${type}-ref-${id}`,
});

const tagRef = (id: string) => ref('tag', id);

describe('tagIdToReference', () => {
it('returns a reference for given tag id', () => {
expect(tagIdToReference('some-tag-id')).toEqual({
id: 'some-tag-id',
type: 'tag',
name: 'tag-ref-some-tag-id',
});
});
});

describe('replaceTagReferences', () => {
it('updates the tag references', () => {
expect(
replaceTagReferences([tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3')], ['tag-2', 'tag-4'])
).toEqual([tagRef('tag-2'), tagRef('tag-4')]);
});
it('leaves the non-tag references unchanged', () => {
expect(
replaceTagReferences(
[ref('dashboard', 'dash-1'), tagRef('tag-1'), ref('lens', 'lens-1'), tagRef('tag-2')],
['tag-2', 'tag-4']
)
).toEqual([
ref('dashboard', 'dash-1'),
ref('lens', 'lens-1'),
tagRef('tag-2'),
tagRef('tag-4'),
]);
});
});

describe('updateTagReferences', () => {
it('adds the `toAdd` tag references', () => {
expect(
updateTagReferences({
references: [tagRef('tag-1'), tagRef('tag-2')],
toAdd: ['tag-3', 'tag-4'],
})
).toEqual([tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3'), tagRef('tag-4')]);
});

it('removes the `toRemove` tag references', () => {
expect(
updateTagReferences({
references: [tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3'), tagRef('tag-4')],
toRemove: ['tag-1', 'tag-3'],
})
).toEqual([tagRef('tag-2'), tagRef('tag-4')]);
});

it('accepts both parameters at the same time', () => {
expect(
updateTagReferences({
references: [tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3'), tagRef('tag-4')],
toRemove: ['tag-1', 'tag-3'],
toAdd: ['tag-5', 'tag-6'],
})
).toEqual([tagRef('tag-2'), tagRef('tag-4'), tagRef('tag-5'), tagRef('tag-6')]);
});

it('does not create a duplicate reference when adding an already assigned tag', () => {
expect(
updateTagReferences({
references: [tagRef('tag-1'), tagRef('tag-2')],
toAdd: ['tag-1', 'tag-3'],
})
).toEqual([tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3')]);
});

it('ignores non-existing `toRemove` ids', () => {
expect(
updateTagReferences({
references: [tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3')],
toRemove: ['tag-2', 'unknown'],
})
).toEqual([tagRef('tag-1'), tagRef('tag-3')]);
});

it('throws if the same id is present in both `toAdd` and `toRemove`', () => {
expect(() =>
updateTagReferences({
references: [tagRef('tag-1'), tagRef('tag-2'), tagRef('tag-3')],
toAdd: ['tag-1', 'tag-2'],
toRemove: ['tag-2', 'tag-3'],
})
).toThrowErrorMatchingInlineSnapshot(
`"Some ids from 'toAdd' also present in 'toRemove': [tag-2]"`
);
});

it('preserves the non-tag references', () => {
expect(
updateTagReferences({
references: [
ref('dashboard', 'dash-1'),
tagRef('tag-1'),
ref('lens', 'lens-1'),
tagRef('tag-2'),
],
toAdd: ['tag-3'],
toRemove: ['tag-1'],
})
).toEqual([
ref('dashboard', 'dash-1'),
ref('lens', 'lens-1'),
tagRef('tag-2'),
tagRef('tag-3'),
]);
});
});
65 changes: 65 additions & 0 deletions x-pack/plugins/saved_objects_tagging/common/references.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* 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 { uniq, intersection } from 'lodash';
import { SavedObjectReference } from '../../../../src/core/types';
import { tagSavedObjectTypeName } from './constants';

/**
* Create a {@link SavedObjectReference | reference} for given tag id.
*/
export const tagIdToReference = (tagId: string): SavedObjectReference => ({
type: tagSavedObjectTypeName,
id: tagId,
name: `tag-ref-${tagId}`,
});

/**
* Update the given `references` array, replacing all the `tag` references with
* references for the given `newTagIds`, while preserving all references to non-tag objects.
*/
export const replaceTagReferences = (
references: SavedObjectReference[],
newTagIds: string[]
): SavedObjectReference[] => {
return [
...references.filter(({ type }) => type !== tagSavedObjectTypeName),
...newTagIds.map(tagIdToReference),
];
};

/**
* Update the given `references` array, adding references to `toAdd` tag ids and removing references
* to `toRemove` tag ids.
* All references to non-tag objects will be preserved.
*
* @remarks: Having the same id(s) in `toAdd` and `toRemove` will result in an error.
*/
export const updateTagReferences = ({
references,
toAdd = [],
toRemove = [],
}: {
references: SavedObjectReference[];
toAdd?: string[];
toRemove?: string[];
}): SavedObjectReference[] => {
const duplicates = intersection(toAdd, toRemove);
if (duplicates.length > 0) {
throw new Error(`Some ids from 'toAdd' also present in 'toRemove': [${duplicates.join(', ')}]`);
}

const nonTagReferences = references.filter(({ type }) => type !== tagSavedObjectTypeName);
const newTagIds = uniq([
...references
.filter(({ type }) => type === tagSavedObjectTypeName)
.map(({ id }) => id)
.filter((id) => !toRemove.includes(id)),
...toAdd,
]);

return [...nonTagReferences, ...newTagIds.map(tagIdToReference)];
};
19 changes: 16 additions & 3 deletions x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@
import { SavedObject, SavedObjectReference } from 'src/core/types';
import { Tag, TagAttributes } from '../types';
import { TagsCapabilities } from '../capabilities';
import { AssignableObject } from '../assignments';

export const createTagReference = (id: string): SavedObjectReference => ({
type: 'tag',
export const createReference = (type: string, id: string): SavedObjectReference => ({
type,
id,
name: `tag-ref-${id}`,
name: `${type}-ref-${id}`,
});

export const createTagReference = (id: string) => createReference('tag', id);

export const createSavedObject = (parts: Partial<SavedObject>): SavedObject => ({
type: 'tag',
id: 'id',
Expand Down Expand Up @@ -46,3 +49,13 @@ export const createTagCapabilities = (parts: Partial<TagsCapabilities> = {}): Ta
viewConnections: true,
...parts,
});

export const createAssignableObject = (
parts: Partial<AssignableObject> = {}
): AssignableObject => ({
type: 'type',
id: 'id',
title: 'title',
tags: [],
...parts,
});
Loading

0 comments on commit 446390d

Please sign in to comment.