{
+ return await this.options.baseClient.removeReferencesTo(type, id, options);
+ }
+
/**
* Strips encrypted attributes from any non-bulk Saved Objects API response. If type isn't
* registered, response is returned as is.
diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap
index 63a59d59d6d07..f616daebf662a 100644
--- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap
+++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap
@@ -82,6 +82,7 @@ Array [
"canvas-workpad",
"lens",
"map",
+ "tag",
],
},
"ui": Array [
@@ -115,6 +116,7 @@ Array [
"map",
"dashboard",
"query",
+ "tag",
],
},
"ui": Array [
@@ -437,6 +439,7 @@ Array [
"read": Array [
"index-pattern",
"search",
+ "tag",
],
},
"ui": Array [
@@ -467,6 +470,7 @@ Array [
"visualization",
"query",
"lens",
+ "tag",
],
},
"ui": Array [
diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts
index d420aea49c6e1..46dd5fd086b4a 100644
--- a/x-pack/plugins/features/server/oss_features.ts
+++ b/x-pack/plugins/features/server/oss_features.ts
@@ -88,7 +88,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
catalogue: ['visualize'],
savedObject: {
all: ['visualization', 'query', 'lens'],
- read: ['index-pattern', 'search'],
+ read: ['index-pattern', 'search', 'tag'],
},
ui: ['show', 'delete', 'save', 'saveQuery'],
},
@@ -97,7 +97,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
catalogue: ['visualize'],
savedObject: {
all: [],
- read: ['index-pattern', 'search', 'visualization', 'query', 'lens'],
+ read: ['index-pattern', 'search', 'visualization', 'query', 'lens', 'tag'],
},
ui: ['show'],
},
@@ -155,6 +155,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
'canvas-workpad',
'lens',
'map',
+ 'tag',
],
},
ui: ['createNew', 'show', 'showWriteControls', 'saveQuery'],
@@ -174,6 +175,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS
'map',
'dashboard',
'query',
+ 'tag',
],
},
ui: ['show'],
diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts
index 421a72c989757..61ed01b53a969 100644
--- a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts
+++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts
@@ -18,7 +18,6 @@ import {
checkForDuplicateTitle,
saveWithConfirmation,
isErrorNonFatal,
- SavedObjectKibanaServices,
} from '../../../../../src/plugins/saved_objects/public';
import {
injectReferences,
@@ -176,7 +175,7 @@ export async function saveSavedWorkspace(
savedObject as any,
isTitleDuplicateConfirmed,
onTitleDuplicate,
- services as SavedObjectKibanaServices
+ services
);
savedObject.isSaving = true;
diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json
index 100527accd1b9..c23c43120050c 100644
--- a/x-pack/plugins/lens/kibana.json
+++ b/x-pack/plugins/lens/kibana.json
@@ -15,7 +15,7 @@
"uiActions",
"embeddable"
],
- "optionalPlugins": ["usageCollection", "taskManager", "globalSearch"],
+ "optionalPlugins": ["usageCollection", "taskManager", "globalSearch", "savedObjectsTagging"],
"configPath": ["xpack", "lens"],
"extraPublicDirs": ["common/constants"],
"requiredBundles": ["savedObjects", "kibanaUtils", "kibanaReact", "embeddable"]
diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx
index c8c0f6d322118..831dd58c373a7 100644
--- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx
+++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx
@@ -503,7 +503,7 @@ describe('Lens App', () => {
async function testSave(inst: ReactWrapper, saveProps: SaveProps) {
await getButton(inst).run(inst.getDOMNode());
inst.update();
- const handler = inst.find('[data-test-subj="lnsApp_saveModalOrigin"]').prop('onSave') as (
+ const handler = inst.find('SavedObjectSaveModalOrigin').prop('onSave') as (
p: unknown
) => void;
handler(saveProps);
diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx
index 366a27a8a9a05..cdd701271be2c 100644
--- a/x-pack/plugins/lens/public/app_plugin/app.tsx
+++ b/x-pack/plugins/lens/public/app_plugin/app.tsx
@@ -19,7 +19,6 @@ import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import {
OnSaveProps,
checkForDuplicateTitle,
- SavedObjectSaveModalOrigin,
} from '../../../../../src/plugins/saved_objects/public';
import { injectFilterReferences } from '../persistence';
import { NativeRenderer } from '../native_renderer';
@@ -33,6 +32,7 @@ import {
import { LENS_EMBEDDABLE_TYPE, getFullPath } from '../../common';
import { LensAppProps, LensAppServices, LensAppState } from './types';
import { getLensTopNavConfig } from './lens_top_nav';
+import { TagEnhancedSavedObjectSaveModalOrigin } from './tags_saved_object_save_modal_origin_wrapper';
import {
LensByReferenceInput,
LensEmbeddableInput,
@@ -59,6 +59,7 @@ export function App({
notifications,
attributeService,
savedObjectsClient,
+ savedObjectsTagging,
getOriginatingAppName,
// Temporarily required until the 'by value' paradigm is default.
@@ -350,16 +351,24 @@ export function App({
returnToOrigin: boolean;
onTitleDuplicate?: OnSaveProps['onTitleDuplicate'];
newDescription?: string;
+ newTags?: string[];
},
options: { saveToLibrary: boolean }
) => {
if (!lastKnownDoc) {
return;
}
+
+ let references = lastKnownDoc.references;
+ if (savedObjectsTagging && saveProps.newTags) {
+ references = savedObjectsTagging.ui.updateTagsReferences(references, saveProps.newTags);
+ }
+
const docToSave = {
...getLastKnownDocWithoutPinnedFilters()!,
description: saveProps.newDescription,
title: saveProps.newTitle,
+ references,
};
// Required to serialize filters in by value mode until
@@ -508,6 +517,11 @@ export function App({
},
});
+ const tagsIds =
+ state.persistedDoc && savedObjectsTagging
+ ? savedObjectsTagging.ui.getTagIdsFromReferences(state.persistedDoc.references)
+ : [];
+
return (
<>
@@ -623,7 +637,9 @@ export function App({
)}
{lastKnownDoc && state.isSaveModalVisible && (
- runSave(props, { saveToLibrary: true })}
onClose={() => {
diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx
index ac5d145eedd5b..c74ac951907e4 100644
--- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx
+++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx
@@ -42,7 +42,7 @@ export async function mountApp(
) {
const { createEditorFrame, getByValueFeatureFlag, attributeService } = mountProps;
const [coreStart, startDependencies] = await core.getStartServices();
- const { data, navigation, embeddable } = startDependencies;
+ const { data, navigation, embeddable, savedObjectsTagging } = startDependencies;
const instance = await createEditorFrame();
const storage = new Storage(localStorage);
@@ -54,6 +54,7 @@ export async function mountApp(
data,
storage,
navigation,
+ savedObjectsTagging,
attributeService: await attributeService(),
http: coreStart.http,
chrome: coreStart.chrome,
diff --git a/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_origin_wrapper.tsx b/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_origin_wrapper.tsx
new file mode 100644
index 0000000000000..a904ecd05909a
--- /dev/null
+++ b/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_origin_wrapper.tsx
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { FC, useState, useMemo, useCallback } from 'react';
+import {
+ OriginSaveModalProps,
+ SavedObjectSaveModalOrigin,
+ OnSaveProps,
+ SaveModalState,
+} from '../../../../../src/plugins/saved_objects/public';
+import { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public';
+
+type TagEnhancedSavedObjectSaveModalOriginProps = Omit & {
+ initialTags: string[];
+ savedObjectsTagging?: SavedObjectTaggingPluginStart;
+ onSave: (props: OnSaveProps & { returnToOrigin: boolean; newTags?: string[] }) => void;
+};
+
+export const TagEnhancedSavedObjectSaveModalOrigin: FC = ({
+ initialTags,
+ onSave,
+ savedObjectsTagging,
+ options,
+ ...otherProps
+}) => {
+ const [selectedTags, setSelectedTags] = useState(initialTags);
+
+ const tagSelectorOption = useMemo(
+ () =>
+ savedObjectsTagging ? (
+
+ ) : undefined,
+ [savedObjectsTagging, initialTags]
+ );
+
+ const tagEnhancedOptions =
+ typeof options === 'function' ? (
+ (state: SaveModalState) => {
+ return (
+ <>
+ {tagSelectorOption}
+ {options(state)}
+ >
+ );
+ }
+ ) : (
+ <>
+ {tagSelectorOption}
+ {options}
+ >
+ );
+
+ const tagEnhancedOnSave: OriginSaveModalProps['onSave'] = useCallback(
+ (saveOptions) => {
+ onSave({
+ ...saveOptions,
+ newTags: selectedTags,
+ });
+ },
+ [onSave, selectedTags]
+ );
+
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts
index bd5a9b5a8ed0a..6c222bed7a83f 100644
--- a/x-pack/plugins/lens/public/app_plugin/types.ts
+++ b/x-pack/plugins/lens/public/app_plugin/types.ts
@@ -28,6 +28,7 @@ import { NavigationPublicPluginStart } from '../../../../../src/plugins/navigati
import { LensAttributeService } from '../lens_attribute_service';
import { IStorageWrapper } from '../../../../../src/plugins/kibana_utils/public';
import { DashboardFeatureFlagConfig } from '../../../../../src/plugins/dashboard/public';
+import type { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public';
import {
VisualizeFieldContext,
ACTION_VISUALIZE_LENS_FIELD,
@@ -99,6 +100,7 @@ export interface LensAppServices {
navigation: NavigationPublicPluginStart;
attributeService: LensAttributeService;
savedObjectsClient: SavedObjectsStart['client'];
+ savedObjectsTagging?: SavedObjectTaggingPluginStart;
getOriginatingAppName: () => string | undefined;
// Temporarily required until the 'by value' paradigm is default.
diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts
index 533efcbfe427e..dddbadec00cf8 100644
--- a/x-pack/plugins/lens/public/plugin.ts
+++ b/x-pack/plugins/lens/public/plugin.ts
@@ -27,6 +27,7 @@ import {
} from './datatable_visualization';
import { PieVisualization, PieVisualizationPluginSetupPlugins } from './pie_visualization';
import { AppNavLinkStatus } from '../../../../src/core/public';
+import type { SavedObjectTaggingPluginStart } from '../../saved_objects_tagging/public';
import {
UiActionsStart,
@@ -58,6 +59,7 @@ export interface LensPluginStartDependencies {
uiActions: UiActionsStart;
dashboard: DashboardStart;
embeddable: EmbeddableStart;
+ savedObjectsTagging?: SavedObjectTaggingPluginStart;
}
export class LensPlugin {
private datatableVisualization: DatatableVisualization;
diff --git a/x-pack/plugins/saved_objects_tagging/README.md b/x-pack/plugins/saved_objects_tagging/README.md
new file mode 100644
index 0000000000000..5e4281a8c4e7d
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/README.md
@@ -0,0 +1,3 @@
+# SavedObjectsTagging
+
+Add tagging capability to saved objects
\ No newline at end of file
diff --git a/x-pack/plugins/saved_objects_tagging/common/capabilities.test.ts b/x-pack/plugins/saved_objects_tagging/common/capabilities.test.ts
new file mode 100644
index 0000000000000..75bb4f72d7247
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/common/capabilities.test.ts
@@ -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 { Capabilities } from 'src/core/types';
+import { getTagsCapabilities } from './capabilities';
+import { tagFeatureId } from './constants';
+
+const createCapabilities = (taggingCaps: Record | undefined): Capabilities => ({
+ navLinks: {},
+ management: {},
+ catalogue: {},
+ ...(taggingCaps ? { [tagFeatureId]: taggingCaps } : {}),
+});
+
+describe('getTagsCapabilities', () => {
+ it('generates the tag capabilities', () => {
+ expect(
+ getTagsCapabilities(
+ createCapabilities({
+ view: true,
+ create: false,
+ edit: false,
+ delete: false,
+ assign: true,
+ })
+ )
+ ).toEqual({
+ view: true,
+ create: false,
+ edit: false,
+ delete: false,
+ assign: true,
+ viewConnections: false,
+ });
+ });
+
+ it('returns all capabilities as disabled if the tag feature in not present', () => {
+ expect(getTagsCapabilities(createCapabilities(undefined))).toEqual({
+ view: false,
+ create: false,
+ edit: false,
+ delete: false,
+ assign: false,
+ viewConnections: false,
+ });
+ });
+
+ it('populates `viewConnections` from the so management capabilities', () => {
+ expect(
+ getTagsCapabilities({
+ ...createCapabilities(undefined),
+ ...{
+ savedObjectsManagement: {
+ read: true,
+ },
+ },
+ })
+ ).toEqual(
+ expect.objectContaining({
+ viewConnections: true,
+ })
+ );
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/common/capabilities.ts b/x-pack/plugins/saved_objects_tagging/common/capabilities.ts
new file mode 100644
index 0000000000000..6171c98ef0510
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/common/capabilities.ts
@@ -0,0 +1,32 @@
+/*
+ * 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 { Capabilities } from 'src/core/types';
+import { tagFeatureId } from './constants';
+
+/**
+ * Represent the UI capabilities for the `savedObjectsTagging` section of `Capabilities`
+ */
+export interface TagsCapabilities {
+ view: boolean;
+ create: boolean;
+ edit: boolean;
+ delete: boolean;
+ assign: boolean;
+ viewConnections: boolean;
+}
+
+export const getTagsCapabilities = (capabilities: Capabilities): TagsCapabilities => {
+ const rawTagCapabilities = capabilities[tagFeatureId];
+ return {
+ view: (rawTagCapabilities?.view as boolean) ?? false,
+ create: (rawTagCapabilities?.create as boolean) ?? false,
+ edit: (rawTagCapabilities?.edit as boolean) ?? false,
+ delete: (rawTagCapabilities?.delete as boolean) ?? false,
+ assign: (rawTagCapabilities?.assign as boolean) ?? false,
+ viewConnections: (capabilities.savedObjectsManagement?.read as boolean) ?? false,
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/common/constants.ts b/x-pack/plugins/saved_objects_tagging/common/constants.ts
new file mode 100644
index 0000000000000..8f7ba86973f3c
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/common/constants.ts
@@ -0,0 +1,9 @@
+/*
+ * 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.
+ */
+
+export const tagFeatureId = 'savedObjectsTagging';
+export const tagSavedObjectTypeName = 'tag';
+export const tagManagementSectionId = 'tags';
diff --git a/x-pack/plugins/saved_objects_tagging/common/index.ts b/x-pack/plugins/saved_objects_tagging/common/index.ts
new file mode 100644
index 0000000000000..4bb2e8840e4e3
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/common/index.ts
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ */
+
+export { TagsCapabilities, getTagsCapabilities } from './capabilities';
+export { tagFeatureId, tagSavedObjectTypeName, tagManagementSectionId } from './constants';
+export { TagWithRelations, TagAttributes, Tag, ITagsClient, TagSavedObject } from './types';
+export {
+ TagValidation,
+ validateTagColor,
+ validateTagName,
+ validateTagDescription,
+ tagNameMinLength,
+ tagNameMaxLength,
+ tagDescriptionMaxLength,
+} from './validation';
diff --git a/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts b/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts
new file mode 100644
index 0000000000000..80d2dbc0b1566
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/common/test_utils/index.ts
@@ -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 { SavedObject, SavedObjectReference } from 'src/core/types';
+import { Tag, TagAttributes } from '../types';
+
+export const createTagReference = (id: string): SavedObjectReference => ({
+ type: 'tag',
+ id,
+ name: `tag-ref-${id}`,
+});
+
+export const createSavedObject = (parts: Partial): SavedObject => ({
+ type: 'tag',
+ id: 'id',
+ references: [],
+ attributes: {},
+ ...parts,
+});
+
+export const createTag = (parts: Partial = {}): Tag => ({
+ id: 'tag-id',
+ name: 'some-tag',
+ description: 'Some tag',
+ color: '#FF00CC',
+ ...parts,
+});
+
+export const createTagAttributes = (parts: Partial = {}): TagAttributes => ({
+ name: 'some-tag',
+ description: 'Some tag',
+ color: '#FF00CC',
+ ...parts,
+});
diff --git a/x-pack/plugins/saved_objects_tagging/common/types.ts b/x-pack/plugins/saved_objects_tagging/common/types.ts
new file mode 100644
index 0000000000000..c73ef30659bff
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/common/types.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 { SavedObject } from 'src/core/types';
+import type { Tag, TagAttributes } from '../../../../src/plugins/saved_objects_tagging_oss/common';
+
+export type TagSavedObject = SavedObject;
+
+export type TagWithRelations = Tag & {
+ /**
+ * The number of objects that are assigned to this tag.
+ */
+ relationCount: number;
+};
+
+// re-export types from oss definition
+export type {
+ Tag,
+ TagAttributes,
+ ITagsClient,
+} from '../../../../src/plugins/saved_objects_tagging_oss/common';
diff --git a/x-pack/plugins/saved_objects_tagging/common/validation.test.ts b/x-pack/plugins/saved_objects_tagging/common/validation.test.ts
new file mode 100644
index 0000000000000..232387e964cbf
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/common/validation.test.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 { validateTagColor, validateTagName, validateTagDescription } from './validation';
+
+describe('Tag attributes validation', () => {
+ describe('validateTagName', () => {
+ it('returns an error message if the name is too short', () => {
+ expect(validateTagName('a')).toMatchInlineSnapshot(
+ `"Tag name must be at least 2 characters"`
+ );
+ });
+
+ it('returns an error message if the name is too long', () => {
+ expect(validateTagName('a'.repeat(55))).toMatchInlineSnapshot(
+ `"Tag name may not exceed 50 characters"`
+ );
+ });
+
+ it('returns an error message if the name contains invalid characters', () => {
+ expect(validateTagName('t^ag+name&')).toMatchInlineSnapshot(
+ `"Tag name can only include a-z, 0-9, _, -,:."`
+ );
+ });
+ });
+
+ describe('validateTagColor', () => {
+ it('returns no error for valid uppercase hex colors', () => {
+ expect(validateTagColor('#F7D8C4')).toBeUndefined();
+ });
+ it('returns no error for valid lowercase hex colors', () => {
+ expect(validateTagColor('#4ac1b7')).toBeUndefined();
+ });
+ it('returns no error for valid mixed case hex colors', () => {
+ expect(validateTagColor('#AfeBdC')).toBeUndefined();
+ });
+ it('returns an error for 3 chars hex colors', () => {
+ expect(validateTagColor('#AAA')).toMatchInlineSnapshot(
+ `"Tag color must be a valid hex color"`
+ );
+ });
+ it('returns an error for invalid hex colors', () => {
+ expect(validateTagColor('#Z1B2C3')).toMatchInlineSnapshot(
+ `"Tag color must be a valid hex color"`
+ );
+ });
+ it('returns an error for other strings', () => {
+ expect(validateTagColor('hello dolly')).toMatchInlineSnapshot(
+ `"Tag color must be a valid hex color"`
+ );
+ });
+ });
+
+ describe('validateTagDescription', () => {
+ it('returns an error message if the description is too long', () => {
+ expect(validateTagDescription('a'.repeat(101))).toMatchInlineSnapshot(
+ `"Tag description may not exceed 100 characters"`
+ );
+ });
+
+ it('returns no error if the description is valid', () => {
+ expect(validateTagDescription('some valid description')).toBeUndefined();
+ });
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/common/validation.ts b/x-pack/plugins/saved_objects_tagging/common/validation.ts
new file mode 100644
index 0000000000000..12149d7bdbe79
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/common/validation.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 { Tag } from './types';
+
+export const tagNameMinLength = 2;
+export const tagNameMaxLength = 50;
+export const tagDescriptionMaxLength = 100;
+
+const hexColorRegexp = /^#[0-9A-F]{6}$/i;
+const nameValidCharsRegexp = /^[0-9A-Z:\-_\s]+$/i;
+
+export interface TagValidation {
+ valid: boolean;
+ warnings: string[];
+ errors: Partial>;
+}
+
+const isHexColor = (color: string): boolean => {
+ return hexColorRegexp.test(color);
+};
+
+export const validateTagColor = (color: string): string | undefined => {
+ if (!isHexColor(color)) {
+ return i18n.translate('xpack.savedObjectsTagging.validation.color.errorInvalid', {
+ defaultMessage: 'Tag color must be a valid hex color',
+ });
+ }
+};
+
+export const validateTagName = (name: string): string | undefined => {
+ if (name.length < tagNameMinLength) {
+ return i18n.translate('xpack.savedObjectsTagging.validation.name.errorTooShort', {
+ defaultMessage: 'Tag name must be at least {length} characters',
+ values: {
+ length: tagNameMinLength,
+ },
+ });
+ }
+ if (name.length > tagNameMaxLength) {
+ return i18n.translate('xpack.savedObjectsTagging.validation.name.errorTooLong', {
+ defaultMessage: 'Tag name may not exceed {length} characters',
+ values: {
+ length: tagNameMaxLength,
+ },
+ });
+ }
+ if (!nameValidCharsRegexp.test(name)) {
+ return i18n.translate('xpack.savedObjectsTagging.validation.name.errorInvalidCharacters', {
+ defaultMessage: 'Tag name can only include a-z, 0-9, _, -,:.',
+ });
+ }
+};
+
+export const validateTagDescription = (description: string): string | undefined => {
+ if (description.length > tagDescriptionMaxLength) {
+ return i18n.translate('xpack.savedObjectsTagging.validation.description.errorTooLong', {
+ defaultMessage: 'Tag description may not exceed {length} characters',
+ values: {
+ length: tagDescriptionMaxLength,
+ },
+ });
+ }
+};
diff --git a/x-pack/plugins/saved_objects_tagging/kibana.json b/x-pack/plugins/saved_objects_tagging/kibana.json
new file mode 100644
index 0000000000000..89c5e7a134339
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/kibana.json
@@ -0,0 +1,10 @@
+{
+ "id": "savedObjectsTagging",
+ "version": "8.0.0",
+ "kibanaVersion": "kibana",
+ "server": true,
+ "ui": true,
+ "configPath": ["xpack", "saved_object_tagging"],
+ "requiredPlugins": ["features", "management", "savedObjectsTaggingOss"],
+ "requiredBundles": ["kibanaReact"]
+}
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/index.ts b/x-pack/plugins/saved_objects_tagging/public/components/base/index.ts
new file mode 100644
index 0000000000000..d81d28d96250a
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/base/index.ts
@@ -0,0 +1,10 @@
+/*
+ * 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.
+ */
+
+export { TagBadge, TagBadgeProps } from './tag_badge';
+export { TagList, TagListProps } from './tag_list';
+export { TagSelector, TagSelectorProps } from './tag_selector';
+export { TagSearchBarOption, TagSearchBarOptionProps } from './tag_searchbar_option';
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_badge.tsx b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_badge.tsx
new file mode 100644
index 0000000000000..406e5df43f999
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_badge.tsx
@@ -0,0 +1,20 @@
+/*
+ * 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, { FC } from 'react';
+import { EuiBadge } from '@elastic/eui';
+import { Tag, TagAttributes } from '../../../common/types';
+
+export interface TagBadgeProps {
+ tag: Tag | TagAttributes;
+}
+
+/**
+ * The badge representation of a Tag, which is the default display to be used for them.
+ */
+export const TagBadge: FC = ({ tag }) => {
+ return {tag.name};
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx
new file mode 100644
index 0000000000000..a9fe4c1c119a1
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx
@@ -0,0 +1,27 @@
+/*
+ * 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, { FC } from 'react';
+import { EuiBadgeGroup } from '@elastic/eui';
+import { Tag, TagAttributes } from '../../../common/types';
+import { TagBadge } from './tag_badge';
+
+export interface TagListProps {
+ tags: Array;
+}
+
+/**
+ * Displays a list of tag
+ */
+export const TagList: FC = ({ tags }) => {
+ return (
+
+ {tags.map((tag) => (
+
+ ))}
+
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_searchbar_option.tsx b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_searchbar_option.tsx
new file mode 100644
index 0000000000000..c505efd9befb5
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_searchbar_option.tsx
@@ -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 React, { FC } from 'react';
+import { EuiHealth, EuiText } from '@elastic/eui';
+import { Tag } from '../../../common';
+import { testSubjFriendly } from '../../utils';
+
+export interface TagSearchBarOptionProps {
+ tag: Tag;
+}
+
+export const TagSearchBarOption: FC = ({ tag }) => {
+ const { name, color } = tag;
+ return (
+
+
+ {name}
+
+
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_selector.tsx b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_selector.tsx
new file mode 100644
index 0000000000000..c915ea4eb82d4
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_selector.tsx
@@ -0,0 +1,178 @@
+/*
+ * 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, { FC, useMemo, useCallback, useState } from 'react';
+import {
+ EuiComboBox,
+ EuiHealth,
+ EuiHighlight,
+ EuiComboBoxOptionOption,
+ EuiIcon,
+ EuiFlexGroup,
+ EuiFlexItem,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { Tag } from '../../../common';
+import { testSubjFriendly } from '../../utils';
+import { CreateModalOpener } from '../edition_modal';
+
+interface CreateOption {
+ type: '__create_option__';
+}
+
+const createOptionValue: CreateOption = {
+ type: '__create_option__',
+};
+
+type TagComboBoxOption = EuiComboBoxOptionOption;
+
+function isTagOption(option: TagComboBoxOption): option is EuiComboBoxOptionOption {
+ const value = option.value as Tag;
+ return value.name !== undefined && value.color !== undefined && value.id !== undefined;
+}
+
+function isCreateOption(
+ option: TagComboBoxOption
+): option is EuiComboBoxOptionOption {
+ const value = option.value as CreateOption;
+ return value.type === '__create_option__';
+}
+
+export interface TagSelectorProps {
+ tags: Tag[];
+ selected: string[];
+ onTagsSelected: (ids: string[]) => void;
+ 'data-test-subj'?: string;
+ allowCreate: boolean;
+ openCreateModal: CreateModalOpener;
+}
+
+const renderCreateOption = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const renderTagOption = (
+ option: EuiComboBoxOptionOption,
+ searchValue: string,
+ contentClassName: string
+) => {
+ const { name, color } = option.value ?? { name: '' };
+ return (
+
+
+ {name}
+
+
+ );
+};
+
+const renderOption = (option: TagComboBoxOption, searchValue: string, contentClassName: string) => {
+ if (isCreateOption(option)) {
+ return renderCreateOption();
+ }
+ // just having an if/else block is not enough for TS to infer the type in the else block. strange...
+ if (isTagOption(option)) {
+ return renderTagOption(option, searchValue, contentClassName);
+ }
+};
+
+export const TagSelector: FC = ({
+ tags,
+ selected,
+ onTagsSelected,
+ allowCreate,
+ openCreateModal,
+ ...otherProps
+}) => {
+ const [currentSearch, setCurrentSearch] = useState('');
+
+ // We are forcing the 'create tag' option to always appear by having its
+ // label matching the current search term. This is a workaround to address
+ // the 'limitations' of the combobox that does not allow that feature
+ // out of the box
+ const createTagOption = useMemo(() => {
+ // label and color will never be actually used for rendering.
+ // label will only be used to check if the option matches the search,
+ // which will always be true because we set its value to the current search.
+ // The extra whitespace is required to avoid the combobox to consider that the value
+ // is selected when closing the dropdown
+ return {
+ label: `${currentSearch} `,
+ color: '#FFFFFF',
+ value: createOptionValue,
+ };
+ }, [currentSearch]);
+
+ // we append the 'create' option if user is allowed to create tags
+ const options: TagComboBoxOption[] = useMemo(() => {
+ return [
+ ...tags.map((tag) => ({
+ label: tag.name,
+ color: tag.color,
+ value: tag,
+ })),
+ ...(allowCreate ? [createTagOption] : []),
+ ];
+ }, [allowCreate, tags, createTagOption]);
+
+ const selectedOptions = useMemo(() => {
+ return options.filter((option) => isTagOption(option) && selected.includes(option.value!.id));
+ }, [selected, options]);
+
+ const onChange = useCallback(
+ (newSelectedOptions: TagComboBoxOption[]) => {
+ // when clicking on the 'create' option, it is selected.
+ // we need to remove it from the selection and then open the
+ // create modal instead.
+ const tagOptions = newSelectedOptions.filter(isTagOption);
+ const selectedIds = tagOptions.map((option) => option.value!.id);
+ onTagsSelected(selectedIds);
+
+ if (newSelectedOptions.find(isCreateOption)) {
+ openCreateModal({
+ defaultValues: {
+ name: currentSearch,
+ },
+ onCreate: (tag) => {
+ onTagsSelected([...selected, tag.id]);
+ },
+ });
+ }
+ },
+ [selected, onTagsSelected, openCreateModal, currentSearch]
+ );
+
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/index.ts b/x-pack/plugins/saved_objects_tagging/public/components/connected/index.ts
new file mode 100644
index 0000000000000..c1c7ee91d60cd
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/index.ts
@@ -0,0 +1,9 @@
+/*
+ * 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.
+ */
+
+export { getConnectedTagListComponent } from './tag_list';
+export { getConnectedTagSelectorComponent } from './tag_selector';
+export { getConnectedSavedObjectModalTagSelectorComponent } from './saved_object_save_modal_tag_selector';
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/saved_object_save_modal_tag_selector.tsx b/x-pack/plugins/saved_objects_tagging/public/components/connected/saved_object_save_modal_tag_selector.tsx
new file mode 100644
index 0000000000000..53e5a27b9b5d7
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/saved_object_save_modal_tag_selector.tsx
@@ -0,0 +1,64 @@
+/*
+ * 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, { FC, useCallback, useState } from 'react';
+import useObservable from 'react-use/lib/useObservable';
+import { EuiFormRow } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { SavedObjectSaveModalTagSelectorComponentProps } from '../../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { TagsCapabilities } from '../../../common';
+import { TagSelector } from '../base';
+import { ITagsCache } from '../../tags';
+import { CreateModalOpener } from '../edition_modal';
+
+interface GetConnectedTagSelectorOptions {
+ cache: ITagsCache;
+ capabilities: TagsCapabilities;
+ openCreateModal: CreateModalOpener;
+}
+
+export const getConnectedSavedObjectModalTagSelectorComponent = ({
+ cache,
+ capabilities,
+ openCreateModal,
+}: GetConnectedTagSelectorOptions): FC => {
+ return ({
+ initialSelection,
+ onTagsSelected: notifySelectionChange,
+ }: SavedObjectSaveModalTagSelectorComponentProps) => {
+ const tags = useObservable(cache.getState$(), cache.getState());
+ const [selected, setSelected] = useState(initialSelection);
+
+ const setSelectedInternal = useCallback(
+ (newSelection: string[]) => {
+ setSelected(newSelection);
+ notifySelectionChange(newSelection);
+ },
+ [notifySelectionChange]
+ );
+
+ return (
+
+ }
+ >
+
+
+ );
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx
new file mode 100644
index 0000000000000..2ac3fe4fc9ad0
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx
@@ -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 React, { FC, useMemo } from 'react';
+import useObservable from 'react-use/lib/useObservable';
+import { SavedObject } from 'src/core/types';
+import { TagListComponentProps } from '../../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { Tag } from '../../../common/types';
+import { getObjectTags } from '../../utils';
+import { TagList } from '../base';
+import { ITagsCache } from '../../tags';
+import { byNameTagSorter } from '../../utils';
+
+interface SavedObjectTagListProps {
+ object: SavedObject;
+ tags: Tag[];
+}
+
+const SavedObjectTagList: FC = ({ object, tags: allTags }) => {
+ const objectTags = useMemo(() => {
+ const { tags } = getObjectTags(object, allTags);
+ tags.sort(byNameTagSorter);
+ return tags;
+ }, [object, allTags]);
+
+ return ;
+};
+
+interface GetConnectedTagListOptions {
+ cache: ITagsCache;
+}
+
+export const getConnectedTagListComponent = ({
+ cache,
+}: GetConnectedTagListOptions): FC => {
+ return (props: TagListComponentProps) => {
+ const tags = useObservable(cache.getState$(), cache.getState());
+ return ;
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_selector.tsx b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_selector.tsx
new file mode 100644
index 0000000000000..04e567c8d2f3b
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_selector.tsx
@@ -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 React, { FC } from 'react';
+import useObservable from 'react-use/lib/useObservable';
+import { TagSelectorComponentProps } from '../../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { TagsCapabilities } from '../../../common';
+import { TagSelector } from '../base';
+import { ITagsCache } from '../../tags';
+import { CreateModalOpener } from '../edition_modal';
+
+interface GetConnectedTagSelectorOptions {
+ cache: ITagsCache;
+ capabilities: TagsCapabilities;
+ openCreateModal: CreateModalOpener;
+}
+
+export const getConnectedTagSelectorComponent = ({
+ cache,
+ capabilities,
+ openCreateModal,
+}: GetConnectedTagSelectorOptions): FC => {
+ return (props: TagSelectorComponentProps) => {
+ const tags = useObservable(cache.getState$(), cache.getState());
+ return (
+
+ );
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx
new file mode 100644
index 0000000000000..d6ccce88e9b4a
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_modal.tsx
@@ -0,0 +1,83 @@
+/*
+ * 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, { FC, useState, useCallback } from 'react';
+import { ITagsClient, Tag, TagAttributes } from '../../../common/types';
+import { TagValidation } from '../../../common/validation';
+import { isServerValidationError } from '../../tags';
+import { getRandomColor, validateTag } from './utils';
+import { CreateOrEditModal } from './create_or_edit_modal';
+
+interface CreateTagModalProps {
+ defaultValues?: Partial;
+ onClose: () => void;
+ onSave: (tag: Tag) => void;
+ tagClient: ITagsClient;
+}
+
+const getDefaultAttributes = (providedDefaults?: Partial): TagAttributes => ({
+ name: '',
+ description: '',
+ color: getRandomColor(),
+ ...providedDefaults,
+});
+
+const initialValidation: TagValidation = {
+ valid: true,
+ warnings: [],
+ errors: {},
+};
+
+export const CreateTagModal: FC = ({
+ defaultValues,
+ tagClient,
+ onClose,
+ onSave,
+}) => {
+ const [validation, setValidation] = useState(initialValidation);
+ const [tagAttributes, setTagAttributes] = useState(
+ getDefaultAttributes(defaultValues)
+ );
+
+ const setField = useCallback(
+ (field: T) => (value: TagAttributes[T]) => {
+ setTagAttributes((current) => ({
+ ...current,
+ [field]: value,
+ }));
+ },
+ []
+ );
+
+ const onSubmit = useCallback(async () => {
+ const clientValidation = validateTag(tagAttributes);
+ setValidation(clientValidation);
+ if (!clientValidation.valid) {
+ return;
+ }
+
+ try {
+ const createdTag = await tagClient.create(tagAttributes);
+ onSave(createdTag);
+ } catch (e) {
+ // if e is HttpFetchError, actual server error payload is in e.body
+ if (isServerValidationError(e.body)) {
+ setValidation(e.body.attributes);
+ }
+ }
+ }, [tagAttributes, tagClient, onSave]);
+
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx
new file mode 100644
index 0000000000000..7baebdae2493e
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/create_or_edit_modal.tsx
@@ -0,0 +1,246 @@
+/*
+ * 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, { FC, useState, useCallback, useMemo } from 'react';
+import {
+ EuiButtonEmpty,
+ EuiButton,
+ EuiModal,
+ EuiModalBody,
+ EuiModalFooter,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+ EuiFlexItem,
+ EuiFlexGroup,
+ EuiForm,
+ EuiFormRow,
+ EuiFieldText,
+ EuiColorPicker,
+ EuiTextArea,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import {
+ TagAttributes,
+ TagValidation,
+ validateTagColor,
+ tagNameMaxLength,
+ tagDescriptionMaxLength,
+} from '../../../common';
+import { TagBadge } from '../../components';
+import { getRandomColor, useIfMounted } from './utils';
+
+interface CreateOrEditModalProps {
+ onClose: () => void;
+ onSubmit: () => Promise;
+ mode: 'create' | 'edit';
+ tag: TagAttributes;
+ validation: TagValidation;
+ setField: (field: T) => (value: TagAttributes[T]) => void;
+}
+
+export const CreateOrEditModal: FC = ({
+ onClose,
+ onSubmit,
+ validation,
+ setField,
+ tag,
+ mode,
+}) => {
+ const ifMounted = useIfMounted();
+ const [submitting, setSubmitting] = useState(false);
+
+ // we don't want this value to change when the user edit the name.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const initialName = useMemo(() => tag.name, []);
+
+ const setName = useMemo(() => setField('name'), [setField]);
+ const setColor = useMemo(() => setField('color'), [setField]);
+ const setDescription = useMemo(() => setField('description'), [setField]);
+
+ const isEdit = useMemo(() => mode === 'edit', [mode]);
+
+ const previewTag: TagAttributes = useMemo(() => {
+ return {
+ ...tag,
+ name: tag.name || 'tag',
+ color: validateTagColor(tag.color) ? '#000000' : tag.color,
+ };
+ }, [tag]);
+
+ const onFormSubmit = useCallback(async () => {
+ setSubmitting(true);
+ await onSubmit();
+ // onSubmit can close the modal, causing errors in the console when the component tries to setState.
+ ifMounted(() => {
+ setSubmitting(false);
+ });
+ }, [ifMounted, onSubmit]);
+
+ return (
+
+
+
+ {isEdit ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ setName(e.target.value)}
+ data-test-subj="createModalField-name"
+ />
+
+
+
+ setColor(getRandomColor())}
+ size="xs"
+ style={{ height: '18px', fontSize: '0.75rem' }}
+ >
+
+
+ }
+ >
+ setColor(text)}
+ format="hex"
+ data-test-subj="createModalField-color"
+ />
+
+
+
+
+
+
+
+ }
+ isInvalid={!!validation.errors.description}
+ error={validation.errors.description}
+ >
+ setDescription(e.target.value)}
+ data-test-subj="createModalField-description"
+ resize="none"
+ fullWidth={true}
+ compressed={true}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isEdit ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx
new file mode 100644
index 0000000000000..b3898dde9e953
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/edit_modal.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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, { FC, useState, useCallback } from 'react';
+import { ITagsClient, Tag, TagAttributes } from '../../../common/types';
+import { TagValidation } from '../../../common/validation';
+import { isServerValidationError } from '../../tags';
+import { CreateOrEditModal } from './create_or_edit_modal';
+import { validateTag } from './utils';
+
+interface EditTagModalProps {
+ tag: Tag;
+ onClose: () => void;
+ onSave: (tag: Tag) => void;
+ tagClient: ITagsClient;
+}
+
+const initialValidation: TagValidation = {
+ valid: true,
+ warnings: [],
+ errors: {},
+};
+
+const getAttributes = (tag: Tag): TagAttributes => {
+ const { id, ...attributes } = tag;
+ return attributes;
+};
+
+export const EditTagModal: FC = ({ tag, onSave, onClose, tagClient }) => {
+ const [validation, setValidation] = useState(initialValidation);
+ const [tagAttributes, setTagAttributes] = useState(getAttributes(tag));
+
+ const setField = useCallback(
+ (field: T) => (value: TagAttributes[T]) => {
+ setTagAttributes((current) => ({
+ ...current,
+ [field]: value,
+ }));
+ },
+ []
+ );
+
+ const onSubmit = useCallback(async () => {
+ const clientValidation = validateTag(tagAttributes);
+ setValidation(clientValidation);
+ if (!clientValidation.valid) {
+ return;
+ }
+
+ try {
+ const createdTag = await tagClient.update(tag.id, tagAttributes);
+ onSave(createdTag);
+ } catch (e) {
+ // if e is HttpFetchError, actual server error payload is in e.body
+ if (isServerValidationError(e.body)) {
+ setValidation(e.body.attributes);
+ }
+ }
+ }, [tagAttributes, tagClient, onSave, tag]);
+
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/index.ts b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/index.ts
new file mode 100644
index 0000000000000..94a12e1ac5693
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { getCreateModalOpener, getEditModalOpener, CreateModalOpener } from './open_modal';
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx
new file mode 100644
index 0000000000000..bfe17b88aa512
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/open_modal.tsx
@@ -0,0 +1,80 @@
+/*
+ * 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 from 'react';
+import { OverlayStart, OverlayRef } from 'src/core/public';
+import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
+import { Tag, TagAttributes } from '../../../common/types';
+import { ITagInternalClient } from '../../tags';
+
+interface GetModalOpenerOptions {
+ overlays: OverlayStart;
+ tagClient: ITagInternalClient;
+}
+
+interface OpenCreateModalOptions {
+ defaultValues?: Partial;
+ onCreate: (tag: Tag) => void;
+}
+
+export type CreateModalOpener = (options: OpenCreateModalOptions) => Promise;
+
+export const getCreateModalOpener = ({
+ overlays,
+ tagClient,
+}: GetModalOpenerOptions): CreateModalOpener => async ({
+ onCreate,
+ defaultValues,
+}: OpenCreateModalOptions) => {
+ const { CreateTagModal } = await import('./create_modal');
+ const modal = overlays.openModal(
+ toMountPoint(
+ {
+ modal.close();
+ }}
+ onSave={(tag) => {
+ modal.close();
+ onCreate(tag);
+ }}
+ tagClient={tagClient}
+ />
+ )
+ );
+ return modal;
+};
+
+interface OpenEditModalOptions {
+ tagId: string;
+ onUpdate: (tag: Tag) => void;
+}
+
+export const getEditModalOpener = ({ overlays, tagClient }: GetModalOpenerOptions) => async ({
+ tagId,
+ onUpdate,
+}: OpenEditModalOptions) => {
+ const { EditTagModal } = await import('./edit_modal');
+ const tag = await tagClient.get(tagId);
+
+ const modal = overlays.openModal(
+ toMountPoint(
+ {
+ modal.close();
+ }}
+ onSave={(saved) => {
+ modal.close();
+ onUpdate(saved);
+ }}
+ tagClient={tagClient}
+ />
+ )
+ );
+
+ return modal;
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/utils.ts b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/utils.ts
new file mode 100644
index 0000000000000..d68c2fbfabdd6
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/edition_modal/utils.ts
@@ -0,0 +1,59 @@
+/*
+ * 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 { useCallback, useEffect, useRef } from 'react';
+import {
+ TagAttributes,
+ TagValidation,
+ validateTagColor,
+ validateTagName,
+ validateTagDescription,
+} from '../../../common';
+
+/**
+ * Returns the hex representation of a random color (e.g `#F1B7E2`)
+ */
+export const getRandomColor = (): string => {
+ return '#' + String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0');
+};
+
+export const validateTag = (tag: TagAttributes): TagValidation => {
+ const validation: TagValidation = {
+ valid: true,
+ warnings: [],
+ errors: {},
+ };
+
+ validation.errors.name = validateTagName(tag.name);
+ validation.errors.color = validateTagColor(tag.color);
+ validation.errors.description = validateTagDescription(tag.description);
+
+ Object.values(validation.errors).forEach((error) => {
+ if (error) {
+ validation.valid = false;
+ }
+ });
+
+ return validation;
+};
+
+export const useIfMounted = () => {
+ const isMounted = useRef(true);
+ useEffect(
+ () => () => {
+ isMounted.current = false;
+ },
+ []
+ );
+
+ const ifMounted = useCallback((func) => {
+ if (isMounted.current && func) {
+ func();
+ }
+ }, []);
+
+ return ifMounted;
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/components/index.ts b/x-pack/plugins/saved_objects_tagging/public/components/index.ts
new file mode 100644
index 0000000000000..45eaf717177dd
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/components/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+
+export {
+ TagSelector,
+ TagSelectorProps,
+ TagList,
+ TagListProps,
+ TagBadge,
+ TagBadgeProps,
+ TagSearchBarOption,
+ TagSearchBarOptionProps,
+} from './base';
diff --git a/x-pack/plugins/saved_objects_tagging/public/config.ts b/x-pack/plugins/saved_objects_tagging/public/config.ts
new file mode 100644
index 0000000000000..4c467eab02291
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/config.ts
@@ -0,0 +1,21 @@
+/*
+ * 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 moment from 'moment';
+
+export interface SavedObjectsTaggingClientConfigRawType {
+ // is a string because the server-side counterpart is a duration
+ // which is serialized to string when sent to the client
+ cache_refresh_interval: string;
+}
+
+export class SavedObjectsTaggingClientConfig {
+ public cacheRefreshInterval: moment.Duration;
+
+ constructor(rawConfig: SavedObjectsTaggingClientConfigRawType) {
+ this.cacheRefreshInterval = moment.duration(rawConfig.cache_refresh_interval);
+ }
+}
diff --git a/x-pack/plugins/saved_objects_tagging/public/index.ts b/x-pack/plugins/saved_objects_tagging/public/index.ts
new file mode 100644
index 0000000000000..9519e40d1d2f5
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/index.ts
@@ -0,0 +1,13 @@
+/*
+ * 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 { PluginInitializerContext } from '../../../../src/core/public';
+import { SavedObjectTaggingPlugin } from './plugin';
+
+export { SavedObjectTaggingPluginStart } from './types';
+
+export const plugin = (initializerContext: PluginInitializerContext) =>
+ new SavedObjectTaggingPlugin(initializerContext);
diff --git a/x-pack/plugins/saved_objects_tagging/public/management/components/header.tsx b/x-pack/plugins/saved_objects_tagging/public/management/components/header.tsx
new file mode 100644
index 0000000000000..73a8b19ae7788
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/management/components/header.tsx
@@ -0,0 +1,71 @@
+/*
+ * 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, { FC } from 'react';
+import {
+ EuiSpacer,
+ EuiTitle,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiText,
+ EuiTextColor,
+ EuiButton,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+interface HeaderProps {
+ canCreate: boolean;
+ onCreate: () => void;
+}
+
+export const Header: FC = ({ canCreate, onCreate }) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ {canCreate && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/management/components/index.ts b/x-pack/plugins/saved_objects_tagging/public/management/components/index.ts
new file mode 100644
index 0000000000000..8435aa0431c23
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/management/components/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export { Header } from './header';
+export { TagTable } from './table';
diff --git a/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx b/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx
new file mode 100644
index 0000000000000..e86977c60ade1
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/management/components/table.tsx
@@ -0,0 +1,197 @@
+/*
+ * 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, { useRef, useEffect, FC } from 'react';
+import { EuiInMemoryTable, EuiBasicTableColumn, EuiLink } 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';
+import { TagsCapabilities, TagWithRelations } from '../../../common';
+import { TagBadge } from '../../components';
+
+interface TagTableProps {
+ loading: boolean;
+ capabilities: TagsCapabilities;
+ tags: TagWithRelations[];
+ selectedTags: TagWithRelations[];
+ onSelectionChange: (selection: TagWithRelations[]) => void;
+ onEdit: (tag: TagWithRelations) => void;
+ onDelete: (tag: TagWithRelations) => void;
+ getTagRelationUrl: (tag: TagWithRelations) => string;
+ onShowRelations: (tag: TagWithRelations) => void;
+}
+
+const tablePagination = {
+ initialPageSize: 20,
+ pageSizeOptions: [5, 10, 20, 50],
+};
+
+const sorting = {
+ sort: {
+ field: 'name',
+ direction: 'asc' as const,
+ },
+};
+
+export const isModifiedOrPrevented = (event: React.MouseEvent) =>
+ event.metaKey || event.altKey || event.ctrlKey || event.shiftKey || event.defaultPrevented;
+
+export const TagTable: FC = ({
+ loading,
+ capabilities,
+ tags,
+ selectedTags,
+ onEdit,
+ onDelete,
+ onShowRelations,
+ getTagRelationUrl,
+}) => {
+ const tableRef = useRef>(null);
+
+ useEffect(() => {
+ if (tableRef.current) {
+ tableRef.current.setSelection(selectedTags);
+ }
+ }, [selectedTags]);
+
+ const actions: Array> = [];
+ if (capabilities.edit) {
+ actions.push({
+ name: i18n.translate('xpack.savedObjectsTagging.management.table.actions.edit.title', {
+ defaultMessage: 'Edit',
+ }),
+ description: i18n.translate(
+ 'xpack.savedObjectsTagging.management.table.actions.edit.description',
+ {
+ defaultMessage: 'Edit this tag',
+ }
+ ),
+ type: 'icon',
+ icon: 'pencil',
+ onClick: (object: TagWithRelations) => onEdit(object),
+ 'data-test-subj': 'tagsTableAction-edit',
+ });
+ }
+ if (capabilities.delete) {
+ actions.push({
+ name: i18n.translate('xpack.savedObjectsTagging.management.table.actions.delete.title', {
+ defaultMessage: 'Delete',
+ }),
+ description: i18n.translate(
+ 'xpack.savedObjectsTagging.management.table.actions.delete.description',
+ {
+ defaultMessage: 'Delete this tag',
+ }
+ ),
+ type: 'icon',
+ icon: 'trash',
+ onClick: (object: TagWithRelations) => onDelete(object),
+ 'data-test-subj': 'tagsTableAction-delete',
+ });
+ }
+
+ const columns: Array> = [
+ {
+ field: 'name',
+ name: i18n.translate('xpack.savedObjectsTagging.management.table.columns.name', {
+ defaultMessage: 'Name',
+ }),
+ sortable: (tag: TagWithRelations) => tag.name,
+ 'data-test-subj': 'tagsTableRowName',
+ render: (name: string, tag: TagWithRelations) => {
+ return ;
+ },
+ },
+ {
+ field: 'description',
+ name: i18n.translate('xpack.savedObjectsTagging.management.table.columns.description', {
+ defaultMessage: 'Description',
+ }),
+ sortable: true,
+ 'data-test-subj': 'tagsTableRowDescription',
+ },
+ {
+ field: 'relationCount',
+ name: i18n.translate('xpack.savedObjectsTagging.management.table.columns.connections', {
+ defaultMessage: 'Connections',
+ }),
+ sortable: (tag: TagWithRelations) => tag.relationCount,
+ 'data-test-subj': 'tagsTableRowConnections',
+ render: (relationCount: number, tag: TagWithRelations) => {
+ if (relationCount < 1) {
+ return undefined;
+ }
+
+ const columnText = (
+
+
+
+ );
+
+ return capabilities.viewConnections ? (
+ // eslint-disable-next-line @elastic/eui/href-or-on-click
+ {
+ if (!isModifiedOrPrevented(e) && e.button === 0) {
+ e.preventDefault();
+ onShowRelations(tag);
+ }
+ }}
+ >
+ {columnText}
+
+ ) : (
+ columnText
+ );
+ },
+ },
+ ...(actions.length
+ ? [
+ {
+ name: i18n.translate('xpack.savedObjectsTagging.management.table.columns.actions', {
+ defaultMessage: 'Actions',
+ }),
+ width: '100px',
+ actions,
+ },
+ ]
+ : []),
+ ];
+
+ return (
+ ({
+ 'data-test-subj': 'tagsTableRow',
+ })}
+ />
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/management/index.ts b/x-pack/plugins/saved_objects_tagging/public/management/index.ts
new file mode 100644
index 0000000000000..f8f035039491b
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/management/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { mountSection } from './mount_section';
diff --git a/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx b/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx
new file mode 100644
index 0000000000000..8d6296c194abd
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/management/mount_section.tsx
@@ -0,0 +1,56 @@
+/*
+ * 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, { FC } from 'react';
+import ReactDOM from 'react-dom';
+import { I18nProvider } from '@kbn/i18n/react';
+import { CoreSetup, ApplicationStart } from 'src/core/public';
+import { ManagementAppMountParams } from '../../../../../src/plugins/management/public';
+import { getTagsCapabilities } from '../../common';
+import { SavedObjectTaggingPluginStart } from '../types';
+import { ITagInternalClient } from '../tags';
+import { TagManagementPage } from './tag_management_page';
+
+interface MountSectionParams {
+ tagClient: ITagInternalClient;
+ core: CoreSetup<{}, SavedObjectTaggingPluginStart>;
+ mountParams: ManagementAppMountParams;
+}
+
+const RedirectToHomeIfUnauthorized: FC<{
+ applications: ApplicationStart;
+}> = ({ applications, children }) => {
+ const allowed = applications.capabilities?.management?.kibana?.tags ?? false;
+ if (!allowed) {
+ applications.navigateToApp('home');
+ return null;
+ }
+ return children! as React.ReactElement;
+};
+
+export const mountSection = async ({ tagClient, core, mountParams }: MountSectionParams) => {
+ const [coreStart] = await core.getStartServices();
+ const { element, setBreadcrumbs } = mountParams;
+ const capabilities = getTagsCapabilities(coreStart.application.capabilities);
+
+ ReactDOM.render(
+
+
+
+
+ ,
+ element
+ );
+
+ return () => {
+ ReactDOM.unmountComponentAtNode(element);
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx b/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx
new file mode 100644
index 0000000000000..4afb15bec6243
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/management/tag_management_page.tsx
@@ -0,0 +1,187 @@
+/*
+ * 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, { useEffect, useCallback, useState, useMemo, FC } from 'react';
+import useMount from 'react-use/lib/useMount';
+import { EuiPageContent } 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 { getTagConnectionsUrl } from './utils';
+
+interface TagManagementPageParams {
+ setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void;
+ core: CoreStart;
+ tagClient: ITagInternalClient;
+ capabilities: TagsCapabilities;
+}
+
+export const TagManagementPage: FC = ({
+ setBreadcrumbs,
+ core,
+ tagClient,
+ capabilities,
+}) => {
+ const { overlays, notifications, application, http } = core;
+ const [loading, setLoading] = useState(false);
+ const [allTags, setAllTags] = useState([]);
+ const [selectedTags, setSelectedTags] = useState([]);
+
+ const createModalOpener = useMemo(() => getCreateModalOpener({ overlays, tagClient }), [
+ overlays,
+ tagClient,
+ ]);
+ const editModalOpener = useMemo(() => getEditModalOpener({ overlays, tagClient }), [
+ overlays,
+ tagClient,
+ ]);
+
+ useEffect(() => {
+ setBreadcrumbs([
+ {
+ text: i18n.translate('xpack.savedObjectsTagging.management.breadcrumb.index', {
+ defaultMessage: 'Tags',
+ }),
+ href: '/',
+ },
+ ]);
+ }, [setBreadcrumbs]);
+
+ const fetchTags = useCallback(async () => {
+ setLoading(true);
+ const { tags } = await tagClient.find({
+ page: 1,
+ perPage: 10000,
+ });
+ setAllTags(tags);
+ setLoading(false);
+ }, [tagClient]);
+
+ useMount(() => {
+ fetchTags();
+ });
+
+ const openCreateModal = useCallback(() => {
+ createModalOpener({
+ onCreate: (createdTag) => {
+ fetchTags();
+ notifications.toasts.addSuccess({
+ title: i18n.translate('xpack.savedObjectsTagging.notifications.createTagSuccessTitle', {
+ defaultMessage: 'Created "{name}" tag',
+ values: {
+ name: createdTag.name,
+ },
+ }),
+ });
+ },
+ });
+ }, [notifications, createModalOpener, fetchTags]);
+
+ const openEditModal = useCallback(
+ (tag: TagWithRelations) => {
+ editModalOpener({
+ tagId: tag.id,
+ onUpdate: (updatedTag) => {
+ fetchTags();
+ notifications.toasts.addSuccess({
+ title: i18n.translate('xpack.savedObjectsTagging.notifications.editTagSuccessTitle', {
+ defaultMessage: 'Saved changes to "{name}" tag',
+ values: {
+ name: updatedTag.name,
+ },
+ }),
+ });
+ },
+ });
+ },
+ [notifications, editModalOpener, fetchTags]
+ );
+
+ const getTagRelationUrl = useCallback(
+ (tag: TagWithRelations) => {
+ return getTagConnectionsUrl(tag, http.basePath);
+ },
+ [http]
+ );
+
+ const showTagRelations = useCallback(
+ (tag: TagWithRelations) => {
+ application.navigateToUrl(getTagRelationUrl(tag));
+ },
+ [application, getTagRelationUrl]
+ );
+
+ const deleteTagWithConfirm = useCallback(
+ async (tag: TagWithRelations) => {
+ const confirmed = await overlays.openConfirm(
+ i18n.translate('xpack.savedObjectsTagging.modals.confirmDelete.text', {
+ defaultMessage:
+ 'By deleting this tag, you will no longer be able to assign it to saved objects. ' +
+ 'This tag will be removed from any saved objects that currently use it. ' +
+ 'Are you sure you wish to proceed?',
+ }),
+ {
+ title: i18n.translate('xpack.savedObjectsTagging.modals.confirmDelete.title', {
+ defaultMessage: 'Delete "{name}" tag',
+ values: {
+ name: tag.name,
+ },
+ }),
+ confirmButtonText: i18n.translate(
+ 'xpack.savedObjectsTagging.modals.confirmDelete.confirmButtonText',
+ {
+ defaultMessage: 'Delete tag',
+ }
+ ),
+ buttonColor: 'danger',
+ }
+ );
+ if (confirmed) {
+ await tagClient.delete(tag.id);
+
+ fetchTags();
+
+ notifications.toasts.addSuccess({
+ title: i18n.translate('xpack.savedObjectsTagging.notifications.deleteTagSuccessTitle', {
+ defaultMessage: 'Deleted "{name}" tag',
+ values: {
+ name: tag.name,
+ },
+ }),
+ });
+ }
+ },
+ [overlays, notifications, fetchTags, tagClient]
+ );
+
+ return (
+
+
+ {
+ setSelectedTags(tags);
+ }}
+ onEdit={(tag) => {
+ openEditModal(tag);
+ }}
+ onDelete={(tag) => {
+ deleteTagWithConfirm(tag);
+ }}
+ getTagRelationUrl={getTagRelationUrl}
+ onShowRelations={(tag) => {
+ showTagRelations(tag);
+ }}
+ />
+
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.test.ts b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.test.ts
new file mode 100644
index 0000000000000..812106b4e3bbf
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.test.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 { httpServiceMock } from '../../../../../../src/core/public/mocks';
+import { getTagConnectionsUrl } from './get_tag_connections_url';
+import { TagWithRelations } from '../../../common/types';
+
+const createTag = (name: string): TagWithRelations => ({
+ id: 'tag-id',
+ name,
+ description: '',
+ color: '#FF0088',
+ relationCount: 42,
+});
+
+const basePath = '/my-base-path';
+
+describe('getTagConnectionsUrl', () => {
+ let httpMock: ReturnType;
+
+ beforeEach(() => {
+ httpMock = httpServiceMock.createStartContract({ basePath });
+ });
+
+ it('appends the basePath to the generated url', () => {
+ const tag = createTag('myTag');
+ expect(getTagConnectionsUrl(tag, httpMock.basePath)).toMatchInlineSnapshot(
+ `"/my-base-path/app/management/kibana/objects?initialQuery=tag%3A(myTag)"`
+ );
+ });
+
+ it('escapes the query', () => {
+ const tag = createTag('tag with spaces');
+ expect(getTagConnectionsUrl(tag, httpMock.basePath)).toMatchInlineSnapshot(
+ `"/my-base-path/app/management/kibana/objects?initialQuery=tag%3A(tag%20with%20spaces)"`
+ );
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.ts b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.ts
new file mode 100644
index 0000000000000..808e0ddcf2d65
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/management/utils/get_tag_connections_url.ts
@@ -0,0 +1,17 @@
+/*
+ * 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 { IBasePath } from 'src/core/public';
+import { TagWithRelations } from '../../../common/types';
+
+/**
+ * Returns the url to use to redirect to the SavedObject management section with given tag
+ * already selected in the query/filter bar.
+ */
+export const getTagConnectionsUrl = (tag: TagWithRelations, basePath: IBasePath) => {
+ const query = encodeURIComponent(`tag:(${tag.name})`);
+ return basePath.prepend(`/app/management/kibana/objects?initialQuery=${query}`);
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/management/utils/index.ts b/x-pack/plugins/saved_objects_tagging/public/management/utils/index.ts
new file mode 100644
index 0000000000000..bc9d0c7ab470a
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/management/utils/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { getTagConnectionsUrl } from './get_tag_connections_url';
diff --git a/x-pack/plugins/saved_objects_tagging/public/mocks.ts b/x-pack/plugins/saved_objects_tagging/public/mocks.ts
new file mode 100644
index 0000000000000..350e3e702f6a9
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/mocks.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { taggingApiMock } from '../../../../src/plugins/saved_objects_tagging_oss/public/mocks';
diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.test.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.test.ts
new file mode 100644
index 0000000000000..16dac75455710
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/plugin.test.ts
@@ -0,0 +1,108 @@
+/*
+ * 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 moment from 'moment';
+import { PublicMethodsOf } from '@kbn/utility-types';
+import { coreMock } from '../../../../src/core/public/mocks';
+import { managementPluginMock } from '../../../../src/plugins/management/public/mocks';
+import { savedObjectTaggingOssPluginMock } from '../../../../src/plugins/saved_objects_tagging_oss/public/mocks';
+import { SavedObjectTaggingPlugin } from './plugin';
+import { SavedObjectsTaggingClientConfigRawType } from './config';
+import { TagsCache } from './tags';
+import { tagsCacheMock } from './tags/tags_cache.mock';
+
+jest.mock('./tags/tags_cache');
+const MockedTagsCache = (TagsCache as unknown) as jest.Mock>;
+
+describe('SavedObjectTaggingPlugin', () => {
+ let plugin: SavedObjectTaggingPlugin;
+ let managementPluginSetup: ReturnType;
+ let savedObjectsTaggingOssPluginSetup: ReturnType;
+
+ beforeEach(() => {
+ const rawConfig: SavedObjectsTaggingClientConfigRawType = {
+ cache_refresh_interval: moment.duration('15', 'minute').toString(),
+ };
+ const initializerContext = coreMock.createPluginInitializerContext(rawConfig);
+
+ plugin = new SavedObjectTaggingPlugin(initializerContext);
+ });
+
+ describe('#setup', () => {
+ beforeEach(() => {
+ managementPluginSetup = managementPluginMock.createSetupContract();
+ savedObjectsTaggingOssPluginSetup = savedObjectTaggingOssPluginMock.createSetup();
+
+ plugin.setup(coreMock.createSetup(), {
+ management: managementPluginSetup,
+ savedObjectsTaggingOss: savedObjectsTaggingOssPluginSetup,
+ });
+ });
+
+ it('register the `tags` app to the `kibana` management section', () => {
+ expect(managementPluginSetup.sections.section.kibana.registerApp).toHaveBeenCalledTimes(1);
+ expect(managementPluginSetup.sections.section.kibana.registerApp).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'tags',
+ title: 'Tags',
+ mount: expect.any(Function),
+ })
+ );
+ });
+ it('register its API app to the `savedObjectsTaggingOss` plugin', () => {
+ expect(savedObjectsTaggingOssPluginSetup.registerTaggingApi).toHaveBeenCalledTimes(1);
+ expect(savedObjectsTaggingOssPluginSetup.registerTaggingApi).toHaveBeenCalledWith(
+ expect.any(Promise)
+ );
+ });
+ });
+
+ describe('#start', () => {
+ beforeEach(() => {
+ managementPluginSetup = managementPluginMock.createSetupContract();
+ savedObjectsTaggingOssPluginSetup = savedObjectTaggingOssPluginMock.createSetup();
+ MockedTagsCache.mockImplementation(() => tagsCacheMock.create());
+
+ plugin.setup(coreMock.createSetup(), {
+ management: managementPluginSetup,
+ savedObjectsTaggingOss: savedObjectsTaggingOssPluginSetup,
+ });
+ });
+
+ it('creates its cache with correct parameters', () => {
+ plugin.start(coreMock.createStart());
+
+ expect(MockedTagsCache).toHaveBeenCalledTimes(1);
+ expect(MockedTagsCache).toHaveBeenCalledWith({
+ refreshHandler: expect.any(Function),
+ refreshInterval: expect.any(Object),
+ });
+
+ const refreshIntervalParam = MockedTagsCache.mock.calls[0][0].refreshInterval;
+
+ expect(moment.isDuration(refreshIntervalParam)).toBe(true);
+ expect(refreshIntervalParam.toString()).toBe('PT15M');
+ });
+
+ it('initializes its cache if not on an anonymous page', async () => {
+ const coreStart = coreMock.createStart();
+ coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(false);
+
+ plugin.start(coreStart);
+
+ expect(MockedTagsCache.mock.instances[0].initialize).not.toHaveBeenCalled();
+ });
+
+ it('does not initialize its cache if on an anonymous page', async () => {
+ const coreStart = coreMock.createStart();
+ coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(true);
+
+ plugin.start(coreStart);
+
+ expect(MockedTagsCache.mock.instances[0].initialize).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.ts
new file mode 100644
index 0000000000000..9a684637f2e92
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/plugin.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { CoreSetup, CoreStart, PluginInitializerContext, Plugin } from 'src/core/public';
+import { ManagementSetup } from '../../../../src/plugins/management/public';
+import { SavedObjectTaggingOssPluginSetup } from '../../../../src/plugins/saved_objects_tagging_oss/public';
+import { tagManagementSectionId } from '../common/constants';
+import { getTagsCapabilities } from '../common/capabilities';
+import { SavedObjectTaggingPluginStart } from './types';
+import { TagsClient, TagsCache } from './tags';
+import { getUiApi } from './ui_api';
+import { SavedObjectsTaggingClientConfig, SavedObjectsTaggingClientConfigRawType } from './config';
+
+interface SetupDeps {
+ management: ManagementSetup;
+ savedObjectsTaggingOss: SavedObjectTaggingOssPluginSetup;
+}
+
+export class SavedObjectTaggingPlugin
+ implements Plugin<{}, SavedObjectTaggingPluginStart, SetupDeps, {}> {
+ private tagClient?: TagsClient;
+ private tagCache?: TagsCache;
+ private readonly config: SavedObjectsTaggingClientConfig;
+
+ constructor(context: PluginInitializerContext) {
+ this.config = new SavedObjectsTaggingClientConfig(
+ context.config.get()
+ );
+ }
+
+ public setup(
+ core: CoreSetup<{}, SavedObjectTaggingPluginStart>,
+ { management, savedObjectsTaggingOss }: SetupDeps
+ ) {
+ const kibanaSection = management.sections.section.kibana;
+ kibanaSection.registerApp({
+ id: tagManagementSectionId,
+ title: i18n.translate('xpack.savedObjectsTagging.management.sectionLabel', {
+ defaultMessage: 'Tags',
+ }),
+ order: 2,
+ mount: async (mountParams) => {
+ const { mountSection } = await import('./management');
+ return mountSection({
+ tagClient: this.tagClient!,
+ core,
+ mountParams,
+ });
+ },
+ });
+
+ savedObjectsTaggingOss.registerTaggingApi(
+ core.getStartServices().then(([_core, _deps, startContract]) => startContract)
+ );
+
+ return {};
+ }
+
+ public start({ http, application, overlays }: CoreStart) {
+ this.tagCache = new TagsCache({
+ refreshHandler: () => this.tagClient!.getAll(),
+ refreshInterval: this.config.cacheRefreshInterval,
+ });
+ this.tagClient = new TagsClient({ http, changeListener: this.tagCache });
+
+ // do not fetch tags on anonymous page
+ if (!http.anonymousPaths.isAnonymous(window.location.pathname)) {
+ // we don't need to wait for this to resolve.
+ this.tagCache.initialize().catch(() => {
+ // cache is resilient to initial load failure. We just need to catch to avoid unhandled promise rejection
+ });
+ }
+
+ return {
+ client: this.tagClient,
+ ui: getUiApi({
+ cache: this.tagCache,
+ client: this.tagClient,
+ capabilities: getTagsCapabilities(application.capabilities),
+ overlays,
+ }),
+ };
+ }
+
+ public stop() {
+ if (this.tagCache) {
+ this.tagCache.stop();
+ }
+ }
+}
diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/errors.ts b/x-pack/plugins/saved_objects_tagging/public/tags/errors.ts
new file mode 100644
index 0000000000000..d353109c151ec
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/tags/errors.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 { TagValidation } from '../../common/validation';
+
+/**
+ * Error returned from the server when attributes validation fails for `create` or `update` operations
+ */
+export interface TagServerValidationError {
+ statusCode: 400;
+ attributes: TagValidation;
+}
+
+export const isServerValidationError = (error: any): error is TagServerValidationError => {
+ return (
+ error &&
+ error.statusCode === 400 &&
+ typeof error.attributes?.valid === 'boolean' &&
+ typeof error.attributes.errors === 'object'
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/index.ts b/x-pack/plugins/saved_objects_tagging/public/tags/index.ts
new file mode 100644
index 0000000000000..9f2b2c5690efb
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/tags/index.ts
@@ -0,0 +1,9 @@
+/*
+ * 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.
+ */
+
+export { TagsClient, ITagInternalClient } from './tags_client';
+export { TagsCache, ITagsChangeListener, ITagsCache } from './tags_cache';
+export { isServerValidationError, TagServerValidationError } from './errors';
diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.mock.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.mock.ts
new file mode 100644
index 0000000000000..731bfe05ffa64
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.mock.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 { of } from 'rxjs';
+import { PublicMethodsOf } from '@kbn/utility-types';
+import { TagsCache } from './tags_cache';
+
+type TagsCacheMock = jest.Mocked>;
+
+const createTagsCacheMock = () => {
+ const mock: TagsCacheMock = {
+ getState: jest.fn(),
+ getState$: jest.fn(),
+ initialize: jest.fn(),
+ stop: jest.fn(),
+
+ onDelete: jest.fn(),
+ onCreate: jest.fn(),
+ onUpdate: jest.fn(),
+ onGetAll: jest.fn(),
+ };
+
+ mock.getState.mockReturnValue([]);
+ mock.getState$.mockReturnValue(of([]));
+ mock.initialize.mockResolvedValue(undefined);
+
+ return mock;
+};
+
+export const tagsCacheMock = {
+ create: createTagsCacheMock,
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.test.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.test.ts
new file mode 100644
index 0000000000000..9260e89f464b7
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.test.ts
@@ -0,0 +1,145 @@
+/*
+ * 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 moment from 'moment';
+import { Tag, TagAttributes } from '../../common/types';
+import { TagsCache, CacheRefreshHandler } from './tags_cache';
+
+const createTag = (parts: Partial): Tag => ({
+ id: 'tag-id',
+ name: 'some-tag',
+ description: 'Some tag',
+ color: '#FF00CC',
+ ...parts,
+});
+
+const createAttributes = (parts: Partial): TagAttributes => ({
+ name: 'some-tag',
+ description: 'Some tag',
+ color: '#FF00CC',
+ ...parts,
+});
+
+const createTags = (ids: string[]): Tag[] =>
+ ids.map((id) =>
+ createTag({
+ id,
+ name: `${id}-name`,
+ description: `${id}-desc`,
+ color: '#FF00CC',
+ })
+ );
+
+const refreshHandler: CacheRefreshHandler = () => createTags(['tag-1', 'tag-2', 'tag-3']);
+
+describe('TagsCache', () => {
+ let tagsCache: TagsCache;
+
+ beforeEach(async () => {
+ tagsCache = new TagsCache({
+ refreshHandler,
+ });
+ await tagsCache.initialize();
+ });
+
+ describe('#onDelete', () => {
+ it('removes the deleted tag from the cache', async () => {
+ tagsCache.onDelete('tag-1');
+
+ expect(tagsCache.getState().map((tag) => tag.id)).toEqual(['tag-2', 'tag-3']);
+ });
+
+ it('does nothing if the specified id is not in the cache', async () => {
+ tagsCache.onDelete('tag-4');
+
+ expect(tagsCache.getState().map((tag) => tag.id)).toEqual(['tag-1', 'tag-2', 'tag-3']);
+ });
+ });
+
+ describe('#onCreate', () => {
+ it('adds the new tag to the cache', async () => {
+ const newTag = createTag({ id: 'new-tag' });
+ tagsCache.onCreate(newTag);
+
+ expect(tagsCache.getState().map((tag) => tag.id)).toEqual([
+ 'tag-1',
+ 'tag-2',
+ 'tag-3',
+ 'new-tag',
+ ]);
+ });
+
+ it('replace the entry from the cache if already existing', async () => {
+ const newTag = createTag({ id: 'tag-2', name: 'new-tag' });
+ tagsCache.onCreate(newTag);
+
+ const cacheState = tagsCache.getState();
+ expect(cacheState.map((tag) => tag.id)).toEqual(['tag-1', 'tag-3', 'tag-2']);
+ expect(cacheState[2]).toEqual(newTag);
+ });
+ });
+
+ describe('#onUpdate', () => {
+ it('replace the entry from the cache', async () => {
+ const updatedAttributes = createAttributes({ name: 'updated-name' });
+ tagsCache.onUpdate('tag-2', updatedAttributes);
+
+ const cacheState = tagsCache.getState();
+ expect(cacheState.map((tag) => tag.id)).toEqual(['tag-1', 'tag-2', 'tag-3']);
+ expect(cacheState[1]).toEqual({
+ id: 'tag-2',
+ ...updatedAttributes,
+ });
+ });
+ });
+
+ describe('#onGetAll', () => {
+ it('refreshes the cache with the new list', () => {
+ const newTags = createTags(['tag-1', 'tag-4', 'tag-5']);
+
+ tagsCache.onGetAll(newTags);
+
+ expect(tagsCache.getState()).toEqual(newTags);
+ });
+ });
+
+ describe('when `refreshInterval` is provided', () => {
+ const refreshInterval = moment.duration('15s');
+
+ let setIntervalSpy: jest.SpyInstance;
+ let clearIntervalSpy: jest.SpyInstance;
+
+ beforeEach(async () => {
+ tagsCache = new TagsCache({
+ refreshHandler,
+ refreshInterval,
+ });
+ setIntervalSpy = jest.spyOn(window, 'setInterval');
+ clearIntervalSpy = jest.spyOn(window, 'clearInterval');
+ });
+
+ it('calls `setInterval` during `initialize` with correct parameters', async () => {
+ await tagsCache.initialize();
+
+ expect(setIntervalSpy).toHaveBeenCalledTimes(1);
+ expect(setIntervalSpy).toHaveBeenCalledWith(
+ expect.any(Function),
+ refreshInterval.asMilliseconds()
+ );
+ });
+
+ it('calls `clearInterval` during `stop` with correct parameters', async () => {
+ const intervalId = 42;
+ setIntervalSpy.mockReturnValue(intervalId);
+
+ await tagsCache.initialize();
+ tagsCache.stop();
+
+ expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
+ expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId);
+ });
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.ts
new file mode 100644
index 0000000000000..b33961d51b48f
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_cache.ts
@@ -0,0 +1,114 @@
+/*
+ * 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 { Duration } from 'moment';
+import { Observable, BehaviorSubject, Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { Tag, TagAttributes } from '../../common/types';
+
+export interface ITagsCache {
+ getState(): Tag[];
+ getState$(): Observable;
+}
+
+export interface ITagsChangeListener {
+ onDelete: (id: string) => void;
+ onCreate: (tag: Tag) => void;
+ onUpdate: (id: string, attributes: TagAttributes) => void;
+ onGetAll: (tags: Tag[]) => void;
+}
+
+export type CacheRefreshHandler = () => Tag[] | Promise;
+
+interface TagsCacheOptions {
+ refreshHandler: CacheRefreshHandler;
+ refreshInterval?: Duration;
+}
+
+/**
+ * Reactive client-side cache of the existing tags, connected to the TagsClient.
+ *
+ * Used (mostly) by the UI components to avoid performing http calls every time a component
+ * needs to retrieve the list of all the existing tags or the tags associated with an object.
+ */
+export class TagsCache implements ITagsCache, ITagsChangeListener {
+ private readonly refreshInterval?: Duration;
+ private readonly refreshHandler: CacheRefreshHandler;
+
+ private intervalId?: number;
+ private readonly internal$: BehaviorSubject;
+ private readonly public$: Observable;
+ private readonly stop$: Subject;
+
+ constructor({ refreshHandler, refreshInterval }: TagsCacheOptions) {
+ this.refreshHandler = refreshHandler;
+ this.refreshInterval = refreshInterval;
+
+ this.stop$ = new Subject();
+ this.internal$ = new BehaviorSubject([]);
+ this.public$ = this.internal$.pipe(takeUntil(this.stop$));
+ }
+
+ public async initialize() {
+ await this.refresh();
+
+ if (this.refreshInterval) {
+ this.intervalId = window.setInterval(() => {
+ this.refresh();
+ }, this.refreshInterval.asMilliseconds());
+ }
+ }
+
+ private async refresh() {
+ try {
+ const tags = await this.refreshHandler();
+ this.internal$.next(tags);
+ } catch (e) {
+ // what should we do here?
+ }
+ }
+
+ public getState() {
+ return this.internal$.getValue();
+ }
+
+ public getState$() {
+ return this.public$;
+ }
+
+ public onDelete(id: string) {
+ this.internal$.next(this.internal$.value.filter((tag) => tag.id !== id));
+ }
+
+ public onCreate(tag: Tag) {
+ this.internal$.next([...this.internal$.value.filter((f) => f.id !== tag.id), tag]);
+ }
+
+ public onUpdate(id: string, attributes: TagAttributes) {
+ this.internal$.next(
+ this.internal$.value.map((tag) => {
+ if (tag.id === id) {
+ return {
+ ...tag,
+ ...attributes,
+ };
+ }
+ return tag;
+ })
+ );
+ }
+
+ public onGetAll(tags: Tag[]) {
+ this.internal$.next(tags);
+ }
+
+ public stop() {
+ if (this.intervalId) {
+ window.clearInterval(this.intervalId);
+ }
+ this.stop$.next();
+ }
+}
diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts
new file mode 100644
index 0000000000000..ac73880e52949
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.test.ts
@@ -0,0 +1,256 @@
+/*
+ * 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 { httpServiceMock } from '../../../../../src/core/public/mocks';
+import { Tag } from '../../common/types';
+import { createTag, createTagAttributes } from '../../common/test_utils';
+import { tagsCacheMock } from './tags_cache.mock';
+import { TagsClient, FindTagsOptions } from './tags_client';
+
+describe('TagsClient', () => {
+ let tagsClient: TagsClient;
+ let changeListener: ReturnType;
+ let http: ReturnType;
+
+ beforeEach(() => {
+ http = httpServiceMock.createSetupContract();
+ changeListener = tagsCacheMock.create();
+ tagsClient = new TagsClient({
+ http,
+ changeListener,
+ });
+ });
+
+ describe('#create', () => {
+ let expectedTag: Tag;
+
+ beforeEach(() => {
+ expectedTag = createTag();
+ http.post.mockResolvedValue({ tag: expectedTag });
+ });
+
+ it('calls `http.post` with the correct parameters', async () => {
+ const attributes = createTagAttributes();
+
+ await tagsClient.create(attributes);
+
+ expect(http.post).toHaveBeenCalledTimes(1);
+ expect(http.post).toHaveBeenCalledWith('/api/saved_objects_tagging/tags/create', {
+ body: JSON.stringify(attributes),
+ });
+ });
+ it('returns the tag object from the response', async () => {
+ const tag = await tagsClient.create(createTagAttributes());
+ expect(tag).toEqual(expectedTag);
+ });
+ 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.create(createTagAttributes())).rejects.toThrowError(error);
+ });
+ it('notifies its changeListener if the http call succeed', async () => {
+ await tagsClient.create(createTagAttributes());
+
+ expect(changeListener.onCreate).toHaveBeenCalledTimes(1);
+ expect(changeListener.onCreate).toHaveBeenCalledWith(expectedTag);
+ });
+ it('ignores potential errors when calling `changeListener.onCreate`', async () => {
+ changeListener.onCreate.mockImplementation(() => {
+ throw new Error('error in onCreate');
+ });
+
+ await expect(tagsClient.create(createTagAttributes())).resolves.toBeDefined();
+ });
+ });
+
+ describe('#update', () => {
+ const tagId = 'test-id';
+ let expectedTag: Tag;
+
+ beforeEach(() => {
+ expectedTag = createTag({ id: tagId });
+ http.post.mockResolvedValue({ tag: expectedTag });
+ });
+
+ it('calls `http.post` with the correct parameters', async () => {
+ const attributes = createTagAttributes();
+
+ await tagsClient.update(tagId, attributes);
+
+ expect(http.post).toHaveBeenCalledTimes(1);
+ expect(http.post).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags/${tagId}`, {
+ body: JSON.stringify(attributes),
+ });
+ });
+ it('returns the tag object from the response', async () => {
+ const tag = await tagsClient.update(tagId, createTagAttributes());
+ expect(tag).toEqual(expectedTag);
+ });
+ 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.update(tagId, createTagAttributes())).rejects.toThrowError(error);
+ });
+ it('notifies its changeListener if the http call succeed', async () => {
+ await tagsClient.update(tagId, createTagAttributes());
+
+ const { id, ...attributes } = expectedTag;
+ expect(changeListener.onUpdate).toHaveBeenCalledTimes(1);
+ expect(changeListener.onUpdate).toHaveBeenCalledWith(id, attributes);
+ });
+ it('ignores potential errors when calling `changeListener.onUpdate`', async () => {
+ changeListener.onUpdate.mockImplementation(() => {
+ throw new Error('error in onUpdate');
+ });
+
+ await expect(tagsClient.update(tagId, createTagAttributes())).resolves.toBeDefined();
+ });
+ });
+
+ describe('#get', () => {
+ const tagId = 'test-id';
+ let expectedTag: Tag;
+
+ beforeEach(() => {
+ expectedTag = createTag({ id: tagId });
+ http.get.mockResolvedValue({ tag: expectedTag });
+ });
+
+ it('calls `http.get` with the correct parameters', async () => {
+ await tagsClient.get(tagId);
+
+ expect(http.get).toHaveBeenCalledTimes(1);
+ expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags/${tagId}`);
+ });
+ it('returns the tag object from the response', async () => {
+ const tag = await tagsClient.get(tagId);
+ expect(tag).toEqual(expectedTag);
+ });
+ 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.get(tagId)).rejects.toThrowError(error);
+ });
+ });
+
+ describe('#getAll', () => {
+ let expectedTags: Tag[];
+
+ beforeEach(() => {
+ expectedTags = [
+ createTag({ id: 'tag-1' }),
+ createTag({ id: 'tag-2' }),
+ createTag({ id: 'tag-3' }),
+ ];
+ http.get.mockResolvedValue({ tags: expectedTags });
+ });
+
+ it('calls `http.get` with the correct parameters', async () => {
+ await tagsClient.getAll();
+
+ expect(http.get).toHaveBeenCalledTimes(1);
+ expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags`);
+ });
+ it('returns the tag objects from the response', async () => {
+ const tags = await tagsClient.getAll();
+ expect(tags).toEqual(expectedTags);
+ });
+ 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.getAll()).rejects.toThrowError(error);
+ });
+ it('notifies its changeListener if the http call succeed', async () => {
+ await tagsClient.getAll();
+
+ expect(changeListener.onGetAll).toHaveBeenCalledTimes(1);
+ expect(changeListener.onGetAll).toHaveBeenCalledWith(expectedTags);
+ });
+ it('ignores potential errors when calling `changeListener.onDelete`', async () => {
+ changeListener.onGetAll.mockImplementation(() => {
+ throw new Error('error in onCreate');
+ });
+
+ await expect(tagsClient.getAll()).resolves.toBeDefined();
+ });
+ });
+
+ describe('#delete', () => {
+ const tagId = 'id-to-delete';
+
+ beforeEach(() => {
+ http.delete.mockResolvedValue({});
+ });
+
+ it('calls `http.delete` with the correct parameters', async () => {
+ await tagsClient.delete(tagId);
+
+ expect(http.delete).toHaveBeenCalledTimes(1);
+ expect(http.delete).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags/${tagId}`);
+ });
+ it('forwards the error from the http call if any', async () => {
+ const error = new Error('something when wrong');
+ http.delete.mockRejectedValue(error);
+
+ await expect(tagsClient.delete(tagId)).rejects.toThrowError(error);
+ });
+ it('notifies its changeListener if the http call succeed', async () => {
+ await tagsClient.delete(tagId);
+
+ expect(changeListener.onDelete).toHaveBeenCalledTimes(1);
+ expect(changeListener.onDelete).toHaveBeenCalledWith(tagId);
+ });
+ it('ignores potential errors when calling `changeListener.onDelete`', async () => {
+ changeListener.onDelete.mockImplementation(() => {
+ throw new Error('error in onCreate');
+ });
+
+ await expect(tagsClient.delete(tagId)).resolves.toBeUndefined();
+ });
+ });
+
+ /////
+
+ 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 });
+ });
+
+ 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);
+
+ await expect(tagsClient.find(findOptions)).rejects.toThrowError(error);
+ });
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts
new file mode 100644
index 0000000000000..3169babb2bae8
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/tags/tags_client.ts
@@ -0,0 +1,117 @@
+/*
+ * 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 { HttpSetup } from 'src/core/public';
+import { Tag, TagAttributes, ITagsClient, TagWithRelations } from '../../common/types';
+import { ITagsChangeListener } from './tags_cache';
+
+export interface TagsClientOptions {
+ http: HttpSetup;
+ changeListener?: ITagsChangeListener;
+}
+
+export interface FindTagsOptions {
+ page?: number;
+ perPage?: number;
+ search?: string;
+}
+
+export interface FindTagsResponse {
+ tags: TagWithRelations[];
+ total: number;
+}
+
+const trapErrors = (fn: () => void) => {
+ try {
+ fn();
+ } catch (e) {
+ // trap
+ }
+};
+
+export interface ITagInternalClient extends ITagsClient {
+ find(options: FindTagsOptions): Promise;
+}
+
+export class TagsClient implements ITagInternalClient {
+ private readonly http: HttpSetup;
+ private readonly changeListener?: ITagsChangeListener;
+
+ constructor({ http, changeListener }: TagsClientOptions) {
+ this.http = http;
+ this.changeListener = changeListener;
+ }
+
+ // public APIs from ITagsClient
+
+ public async create(attributes: TagAttributes) {
+ const { tag } = await this.http.post<{ tag: Tag }>('/api/saved_objects_tagging/tags/create', {
+ body: JSON.stringify(attributes),
+ });
+
+ trapErrors(() => {
+ if (this.changeListener) {
+ this.changeListener.onCreate(tag);
+ }
+ });
+
+ return tag;
+ }
+
+ public async update(id: string, attributes: TagAttributes) {
+ const { tag } = await this.http.post<{ tag: Tag }>(`/api/saved_objects_tagging/tags/${id}`, {
+ body: JSON.stringify(attributes),
+ });
+
+ trapErrors(() => {
+ if (this.changeListener) {
+ const { id: newId, ...newAttributes } = tag;
+ this.changeListener.onUpdate(newId, newAttributes);
+ }
+ });
+
+ return tag;
+ }
+
+ public async get(id: string) {
+ const { tag } = await this.http.get<{ tag: Tag }>(`/api/saved_objects_tagging/tags/${id}`);
+ return tag;
+ }
+
+ public async getAll() {
+ const { tags } = await this.http.get<{ tags: Tag[] }>('/api/saved_objects_tagging/tags');
+
+ trapErrors(() => {
+ if (this.changeListener) {
+ this.changeListener.onGetAll(tags);
+ }
+ });
+
+ return tags;
+ }
+
+ public async delete(id: string) {
+ await this.http.delete<{}>(`/api/saved_objects_tagging/tags/${id}`);
+
+ trapErrors(() => {
+ if (this.changeListener) {
+ this.changeListener.onDelete(id);
+ }
+ });
+ }
+
+ // internal APIs from ITagInternalClient
+
+ public async find({ page, perPage, search }: FindTagsOptions) {
+ return await this.http.get('/internal/saved_objects_tagging/tags/_find', {
+ query: {
+ page,
+ perPage,
+ search,
+ },
+ });
+ }
+}
diff --git a/x-pack/plugins/saved_objects_tagging/public/types.ts b/x-pack/plugins/saved_objects_tagging/public/types.ts
new file mode 100644
index 0000000000000..b30e78d059e56
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/types.ts
@@ -0,0 +1,9 @@
+/*
+ * 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 type { SavedObjectsTaggingApi } from '../../../../src/plugins/saved_objects_tagging_oss/public';
+
+export type SavedObjectTaggingPluginStart = SavedObjectsTaggingApi;
diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts
new file mode 100644
index 0000000000000..5b73ff906ecdd
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/components.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 } from 'src/core/public';
+import { SavedObjectsTaggingApiUiComponent } from '../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { TagsCapabilities } from '../../common';
+import { ITagInternalClient, ITagsCache } from '../tags';
+import {
+ getConnectedTagListComponent,
+ getConnectedTagSelectorComponent,
+ getConnectedSavedObjectModalTagSelectorComponent,
+} from '../components/connected';
+import { getCreateModalOpener } from '../components/edition_modal';
+
+export interface GetComponentsOptions {
+ capabilities: TagsCapabilities;
+ cache: ITagsCache;
+ overlays: OverlayStart;
+ tagClient: ITagInternalClient;
+}
+
+export const getComponents = ({
+ capabilities,
+ cache,
+ overlays,
+ tagClient,
+}: GetComponentsOptions): SavedObjectsTaggingApiUiComponent => {
+ const openCreateModal = getCreateModalOpener({ overlays, tagClient });
+ return {
+ TagList: getConnectedTagListComponent({ cache }),
+ TagSelector: getConnectedTagSelectorComponent({ cache, capabilities, openCreateModal }),
+ SavedObjectSaveModalTagSelector: getConnectedSavedObjectModalTagSelectorComponent({
+ cache,
+ capabilities,
+ openCreateModal,
+ }),
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/convert_name_to_reference.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/convert_name_to_reference.ts
new file mode 100644
index 0000000000000..df207791aa197
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/convert_name_to_reference.ts
@@ -0,0 +1,22 @@
+/*
+ * 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 { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { ITagsCache } from '../tags';
+import { convertTagNameToId } from '../utils';
+
+export interface BuildConvertNameToReferenceOptions {
+ cache: ITagsCache;
+}
+
+export const buildConvertNameToReference = ({
+ cache,
+}: BuildConvertNameToReferenceOptions): SavedObjectsTaggingApiUi['convertNameToReference'] => {
+ return (tagName: string) => {
+ const tagId = convertTagNameToId(tagName, cache.getState());
+ return tagId ? { type: 'tag', id: tagId } : undefined;
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts
new file mode 100644
index 0000000000000..f4a2413dab6e9
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts
@@ -0,0 +1,89 @@
+/*
+ * 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 { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { tagsCacheMock } from '../tags/tags_cache.mock';
+import { Tag } from '../../common/types';
+import { createTag } from '../../common/test_utils';
+import { buildGetSearchBarFilter } from './get_search_bar_filter';
+
+const expectTagOption = (tag: Tag, useName: boolean) => ({
+ value: useName ? tag.name : tag.id,
+ name: tag.name,
+ view: expect.anything(),
+});
+
+describe('getSearchBarFilter', () => {
+ let cache: ReturnType;
+ let getSearchBarFilter: SavedObjectsTaggingApiUi['getSearchBarFilter'];
+
+ beforeEach(() => {
+ cache = tagsCacheMock.create();
+ getSearchBarFilter = buildGetSearchBarFilter({ cache });
+ });
+
+ it('has the correct base configuration', () => {
+ expect(getSearchBarFilter()).toEqual({
+ type: 'field_value_selection',
+ field: 'tag',
+ name: expect.any(String),
+ multiSelect: 'or',
+ options: expect.any(Function),
+ });
+ });
+
+ it('uses the specified field', () => {
+ expect(getSearchBarFilter({ tagField: 'foo' })).toEqual(
+ expect.objectContaining({
+ field: 'foo',
+ })
+ );
+ });
+
+ it('resolves the options', async () => {
+ const tags = [
+ createTag({ id: 'id-1', name: 'name-1' }),
+ createTag({ id: 'id-2', name: 'name-2' }),
+ createTag({ id: 'id-3', name: 'name-3' }),
+ ];
+ cache.getState.mockReturnValue(tags);
+
+ // EUI types for filters are incomplete
+ const { options } = getSearchBarFilter() as any;
+
+ const fetched = await options();
+ expect(fetched).toEqual(tags.map((tag) => expectTagOption(tag, true)));
+ });
+
+ it('sorts the tags by name', async () => {
+ const tag1 = createTag({ id: 'id-1', name: 'aaa' });
+ const tag2 = createTag({ id: 'id-2', name: 'ccc' });
+ const tag3 = createTag({ id: 'id-3', name: 'bbb' });
+
+ cache.getState.mockReturnValue([tag1, tag2, tag3]);
+
+ // EUI types for filters are incomplete
+ const { options } = getSearchBarFilter() as any;
+
+ const fetched = await options();
+ expect(fetched).toEqual([tag1, tag3, tag2].map((tag) => expectTagOption(tag, true)));
+ });
+
+ it('uses the `useName` option', async () => {
+ const tags = [
+ createTag({ id: 'id-1', name: 'name-1' }),
+ createTag({ id: 'id-2', name: 'name-2' }),
+ createTag({ id: 'id-3', name: 'name-3' }),
+ ];
+ cache.getState.mockReturnValue(tags);
+
+ // EUI types for filters are incomplete
+ const { options } = getSearchBarFilter({ useName: false }) as any;
+
+ const fetched = await options();
+ expect(fetched).toEqual(tags.map((tag) => expectTagOption(tag, false)));
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx
new file mode 100644
index 0000000000000..539759a0f1320
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx
@@ -0,0 +1,51 @@
+/*
+ * 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 from 'react';
+import { i18n } from '@kbn/i18n';
+import {
+ SavedObjectsTaggingApiUi,
+ GetSearchBarFilterOptions,
+} from '../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { ITagsCache } from '../tags';
+import { TagSearchBarOption } from '../components';
+import { byNameTagSorter } from '../utils';
+
+export interface BuildGetSearchBarFilterOptions {
+ cache: ITagsCache;
+}
+
+export const buildGetSearchBarFilter = ({
+ cache,
+}: BuildGetSearchBarFilterOptions): SavedObjectsTaggingApiUi['getSearchBarFilter'] => {
+ return ({ useName = true, tagField = 'tag' }: GetSearchBarFilterOptions = {}) => {
+ return {
+ type: 'field_value_selection',
+ field: tagField,
+ name: i18n.translate('xpack.savedObjectsTagging.uiApi.searchBar.filterButtonLabel', {
+ defaultMessage: 'Tags',
+ }),
+ multiSelect: 'or',
+ options: () => {
+ // we are using the promise version of `options` because the handler is called
+ // everytime the filter is opened. That way we can keep in sync in case of tags
+ // that would be added without the searchbar having trigger a re-render.
+ return Promise.resolve(
+ cache
+ .getState()
+ .sort(byNameTagSorter)
+ .map((tag) => {
+ return {
+ value: useName ? tag.name : tag.id,
+ name: tag.name,
+ view: ,
+ };
+ })
+ );
+ },
+ };
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.test.ts
new file mode 100644
index 0000000000000..f1c26aca26c2f
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.test.ts
@@ -0,0 +1,61 @@
+/*
+ * 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 { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { taggingApiMock } from '../../../../../src/plugins/saved_objects_tagging_oss/public/mocks';
+import { tagsCacheMock } from '../tags/tags_cache.mock';
+import { createTagReference, createSavedObject, createTag } from '../../common/test_utils';
+import { buildGetTableColumnDefinition } from './get_table_column_definition';
+
+describe('getTableColumnDefinition', () => {
+ let cache: ReturnType;
+ let components: ReturnType;
+ let getTableColumnDefinition: SavedObjectsTaggingApiUi['getTableColumnDefinition'];
+
+ beforeEach(() => {
+ cache = tagsCacheMock.create();
+ components = taggingApiMock.createComponents();
+
+ getTableColumnDefinition = buildGetTableColumnDefinition({
+ cache,
+ components,
+ });
+ });
+
+ it('returns a valid definition for a EUI field data column', () => {
+ const tableDef = getTableColumnDefinition();
+
+ expect(tableDef).toEqual(
+ expect.objectContaining({
+ field: 'references',
+ name: expect.any(String),
+ description: expect.any(String),
+ sortable: expect.any(Function),
+ render: expect.any(Function),
+ })
+ );
+ });
+
+ it('returns the correct sorting value', () => {
+ const allTags = [
+ createTag({ id: 'tag-1', name: 'Tag 1' }),
+ createTag({ id: 'tag-2', name: 'Tag 2' }),
+ createTag({ id: 'tag-3', name: 'Tag 3' }),
+ ];
+ cache.getState.mockReturnValue(allTags);
+
+ const tagReferences = [createTagReference('tag-3'), createTagReference('tag-1')];
+
+ const savedObject = createSavedObject({
+ references: tagReferences,
+ });
+
+ const { sortable } = getTableColumnDefinition();
+
+ // we know this returns a function even if the generic column signature allows other types
+ expect((sortable as Function)(savedObject)).toEqual('Tag 1');
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.tsx b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.tsx
new file mode 100644
index 0000000000000..e50c163a4814f
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_table_column_definition.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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 from 'react';
+import { i18n } from '@kbn/i18n';
+import { SavedObject, SavedObjectReference } from 'src/core/public';
+import {
+ SavedObjectsTaggingApiUi,
+ SavedObjectsTaggingApiUiComponent,
+} from '../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { ITagsCache } from '../tags';
+import { getTagsFromReferences, byNameTagSorter } from '../utils';
+
+export interface GetTableColumnDefinitionOptions {
+ components: SavedObjectsTaggingApiUiComponent;
+ cache: ITagsCache;
+}
+
+export const buildGetTableColumnDefinition = ({
+ components,
+ cache,
+}: GetTableColumnDefinitionOptions): SavedObjectsTaggingApiUi['getTableColumnDefinition'] => {
+ return () => {
+ return {
+ field: 'references',
+ name: i18n.translate('xpack.savedObjectsTagging.uiApi.table.columnTagsName', {
+ defaultMessage: 'Tags',
+ }),
+ description: i18n.translate('xpack.savedObjectsTagging.uiApi.table.columnTagsDescription', {
+ defaultMessage: 'Tags associated with this saved object',
+ }),
+ sortable: (object: SavedObject) => {
+ const { tags } = getTagsFromReferences(object.references, cache.getState());
+ tags.sort(byNameTagSorter);
+ return tags.length ? tags[0].name : undefined;
+ },
+ render: (references: SavedObjectReference[], object: SavedObject) => {
+ return ;
+ },
+ 'data-test-subj': 'listingTableRowTags',
+ };
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/has_tag_decoration.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/has_tag_decoration.ts
new file mode 100644
index 0000000000000..2245fe752e9a0
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/has_tag_decoration.ts
@@ -0,0 +1,16 @@
+/*
+ * 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 {
+ SavedObjectsTaggingApiUi,
+ TagDecoratedSavedObject,
+} from '../../../../../src/plugins/saved_objects_tagging_oss/public';
+
+export const hasTagDecoration: SavedObjectsTaggingApiUi['hasTagDecoration'] = (
+ object
+): object is TagDecoratedSavedObject => {
+ return 'getTags' in object && 'setTags' in object;
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts
new file mode 100644
index 0000000000000..52ce8812454d9
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 } from 'src/core/public';
+import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { TagsCapabilities } from '../../common';
+import { ITagsCache, ITagInternalClient } from '../tags';
+import { getTagIdsFromReferences, updateTagsReferences } from '../utils';
+import { getComponents } from './components';
+import { buildGetTableColumnDefinition } from './get_table_column_definition';
+import { buildGetSearchBarFilter } from './get_search_bar_filter';
+import { buildParseSearchQuery } from './parse_search_query';
+import { buildConvertNameToReference } from './convert_name_to_reference';
+import { hasTagDecoration } from './has_tag_decoration';
+
+interface GetUiApiOptions {
+ overlays: OverlayStart;
+ capabilities: TagsCapabilities;
+ cache: ITagsCache;
+ client: ITagInternalClient;
+}
+
+export const getUiApi = ({
+ cache,
+ capabilities,
+ client,
+ overlays,
+}: GetUiApiOptions): SavedObjectsTaggingApiUi => {
+ const components = getComponents({ cache, capabilities, overlays, tagClient: client });
+
+ return {
+ components,
+ getTableColumnDefinition: buildGetTableColumnDefinition({ components, cache }),
+ getSearchBarFilter: buildGetSearchBarFilter({ cache }),
+ parseSearchQuery: buildParseSearchQuery({ cache }),
+ convertNameToReference: buildConvertNameToReference({ cache }),
+ hasTagDecoration,
+ getTagIdsFromReferences,
+ updateTagsReferences,
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts
new file mode 100644
index 0000000000000..726e43e02e3b8
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts
@@ -0,0 +1,78 @@
+/*
+ * 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 { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { tagsCacheMock } from '../tags/tags_cache.mock';
+import { createTag } from '../../common/test_utils';
+import { buildParseSearchQuery } from './parse_search_query';
+
+const tagRef = (id: string) => ({
+ id,
+ type: 'tag',
+});
+
+const tags = [
+ createTag({ id: 'id-1', name: 'name-1' }),
+ createTag({ id: 'id-2', name: 'name-2' }),
+ createTag({ id: 'id-3', name: 'name-3' }),
+];
+
+describe('parseSearchQuery', () => {
+ let cache: ReturnType;
+ let parseSearchQuery: SavedObjectsTaggingApiUi['parseSearchQuery'];
+
+ beforeEach(() => {
+ cache = tagsCacheMock.create();
+ cache.getState.mockReturnValue(tags);
+
+ parseSearchQuery = buildParseSearchQuery({ cache });
+ });
+
+ it('returns the search term when there is no field clause', () => {
+ const searchTerm = 'my search term';
+
+ expect(parseSearchQuery(searchTerm)).toEqual({
+ searchTerm,
+ tagReferences: undefined,
+ });
+ });
+
+ it('returns the tag references matching the tag field clause when using `useName: false`', () => {
+ const searchTerm = 'tag:(id-1 OR id-2) my search term';
+
+ expect(parseSearchQuery(searchTerm, { useName: false })).toEqual({
+ searchTerm: 'my search term',
+ tagReferences: [tagRef('id-1'), tagRef('id-2')],
+ });
+ });
+
+ it('returns the tag references matching the tag field clause when using `useName: true`', () => {
+ const searchTerm = 'tag:(name-1 OR name-2) my search term';
+
+ expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({
+ searchTerm: 'my search term',
+ tagReferences: [tagRef('id-1'), tagRef('id-2')],
+ });
+ });
+
+ it('uses the `tagField` option', () => {
+ const searchTerm = 'custom:(name-1 OR name-2) my search term';
+
+ expect(parseSearchQuery(searchTerm, { tagField: 'custom' })).toEqual({
+ searchTerm: 'my search term',
+ tagReferences: [tagRef('id-1'), tagRef('id-2')],
+ });
+ });
+
+ it('ignores names not in the cache', () => {
+ const searchTerm = 'tag:(name-1 OR missing-name) my search term';
+
+ expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({
+ searchTerm: 'my search term',
+ tagReferences: [tagRef('id-1')],
+ });
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts
new file mode 100644
index 0000000000000..138b2a60ad15d
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts
@@ -0,0 +1,64 @@
+/*
+ * 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 { Query } from '@elastic/eui';
+import { SavedObjectsFindOptionsReference } from 'src/core/public';
+import {
+ ParseSearchQueryOptions,
+ SavedObjectsTaggingApiUi,
+} from '../../../../../src/plugins/saved_objects_tagging_oss/public';
+import { ITagsCache } from '../tags';
+
+export interface BuildParseSearchQueryOptions {
+ cache: ITagsCache;
+}
+
+export const buildParseSearchQuery = ({
+ cache,
+}: BuildParseSearchQueryOptions): SavedObjectsTaggingApiUi['parseSearchQuery'] => {
+ return (query: string, { tagField = 'tag', useName = true }: ParseSearchQueryOptions = {}) => {
+ const parsed = Query.parse(query);
+ // from other usages of `Query.parse` in the codebase, it seems that
+ // for empty term, the parsed query can be undefined, even if the type def state otherwise.
+ if (!query) {
+ return {
+ searchTerm: '',
+ tagReferences: [],
+ };
+ }
+
+ let searchTerm: string = '';
+ let tagReferences: SavedObjectsFindOptionsReference[] = [];
+
+ if (parsed.ast.getTermClauses().length) {
+ searchTerm = parsed.ast
+ .getTermClauses()
+ .map((clause: any) => clause.value)
+ .join(' ');
+ }
+ if (parsed.ast.getFieldClauses(tagField)) {
+ const selectedTags = parsed.ast.getFieldClauses(tagField)[0].value as string[];
+ if (useName) {
+ selectedTags.forEach((tagName) => {
+ const found = cache.getState().find((tag) => tag.name === tagName);
+ if (found) {
+ tagReferences.push({
+ type: 'tag',
+ id: found.id,
+ });
+ }
+ });
+ } else {
+ tagReferences = selectedTags.map((tagId) => ({ type: 'tag', id: tagId }));
+ }
+ }
+
+ return {
+ searchTerm,
+ tagReferences: tagReferences.length ? tagReferences : undefined,
+ };
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/public/utils.test.ts b/x-pack/plugins/saved_objects_tagging/public/utils.test.ts
new file mode 100644
index 0000000000000..601a30ce9c892
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/utils.test.ts
@@ -0,0 +1,133 @@
+/*
+ * 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 { SavedObject, SavedObjectReference } from 'src/core/types';
+import {
+ getObjectTags,
+ convertTagNameToId,
+ byNameTagSorter,
+ updateTagsReferences,
+ getTagIdsFromReferences,
+ tagIdToReference,
+} from './utils';
+
+const createTag = (id: string, name: string = id) => ({
+ id,
+ name,
+ description: `desc ${id}`,
+ color: '#FFCC00',
+});
+
+const ref = (type: string, id: string): SavedObjectReference => ({
+ id,
+ type,
+ name: `${type}-ref-${id}`,
+});
+
+const tagRef = (id: string) => ref('tag', id);
+
+const createObject = (refs: SavedObjectReference[]): SavedObject => {
+ return {
+ type: 'unkown',
+ id: 'irrelevant',
+ references: refs,
+ } as SavedObject;
+};
+
+const tag1 = createTag('id-1', 'name-1');
+const tag2 = createTag('id-2', 'name-2');
+const tag3 = createTag('id-3', 'name-3');
+
+const allTags = [tag1, tag2, tag3];
+
+describe('getObjectTags', () => {
+ it('returns the tags for the tag references of the object', () => {
+ const { tags } = getObjectTags(
+ createObject([tagRef('id-1'), ref('dashboard', 'dash-1'), tagRef('id-3')]),
+ allTags
+ );
+
+ expect(tags).toEqual([tag1, tag3]);
+ });
+
+ it('returns the missing references for tags that were not found', () => {
+ const missingRef = tagRef('missing-tag');
+ const refs = [tagRef('id-1'), ref('dashboard', 'dash-1'), missingRef];
+ const { tags, missingRefs } = getObjectTags(createObject(refs), allTags);
+
+ expect(tags).toEqual([tag1]);
+ expect(missingRefs).toEqual([missingRef]);
+ });
+});
+
+describe('convertTagNameToId', () => {
+ it('returns the id for the given tag name', () => {
+ expect(convertTagNameToId('name-2', allTags)).toBe('id-2');
+ });
+
+ it('returns undefined if no tag was found', () => {
+ expect(convertTagNameToId('name-4', allTags)).toBeUndefined();
+ });
+});
+
+describe('byNameTagSorter', () => {
+ it('sorts tags by name', () => {
+ const tags = [
+ createTag('id-1', 'tag-b'),
+ createTag('id-2', 'tag-a'),
+ createTag('id-3', 'tag-d'),
+ createTag('id-4', 'tag-c'),
+ ];
+
+ tags.sort(byNameTagSorter);
+
+ expect(tags.map(({ id }) => id)).toEqual(['id-2', 'id-1', 'id-4', 'id-3']);
+ });
+});
+
+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('getTagIdsFromReferences', () => {
+ it('returns the tag ids from the given references', () => {
+ expect(
+ getTagIdsFromReferences([
+ tagRef('tag-1'),
+ ref('dashboard', 'dash-1'),
+ tagRef('tag-2'),
+ ref('lens', 'lens-1'),
+ ])
+ ).toEqual(['tag-1', 'tag-2']);
+ });
+});
+
+describe('updateTagsReferences', () => {
+ it('updates the tag references', () => {
+ expect(
+ updateTagsReferences([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(
+ updateTagsReferences(
+ [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'),
+ ]);
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/public/utils.ts b/x-pack/plugins/saved_objects_tagging/public/utils.ts
new file mode 100644
index 0000000000000..c74011dc605b6
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/public/utils.ts
@@ -0,0 +1,69 @@
+/*
+ * 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 { SavedObject, SavedObjectReference } from 'src/core/types';
+import { SavedObjectsFindOptionsReference } from 'src/core/public';
+import { Tag, tagSavedObjectTypeName } from '../common';
+
+type SavedObjectReferenceLike = SavedObjectReference | SavedObjectsFindOptionsReference;
+
+export const getObjectTags = (object: SavedObject, allTags: Tag[]) => {
+ return getTagsFromReferences(object.references, allTags);
+};
+
+export const getTagsFromReferences = (references: SavedObjectReference[], allTags: Tag[]) => {
+ const tagReferences = references.filter((ref) => ref.type === tagSavedObjectTypeName);
+
+ const foundTags: Tag[] = [];
+ const missingRefs: SavedObjectReference[] = [];
+
+ tagReferences.forEach((ref) => {
+ const found = allTags.find((tag) => tag.id === ref.id);
+ if (found) {
+ foundTags.push(found);
+ } else {
+ missingRefs.push(ref);
+ }
+ });
+
+ return {
+ tags: foundTags,
+ missingRefs,
+ };
+};
+
+export const convertTagNameToId = (tagName: string, allTags: Tag[]): string | undefined => {
+ const found = allTags.find((tag) => tag.name === tagName);
+ return found?.id;
+};
+
+export const byNameTagSorter = (tagA: Tag, tagB: Tag): number => {
+ return tagA.name.localeCompare(tagB.name);
+};
+
+export const testSubjFriendly = (name: string) => {
+ return name.replace(' ', '_');
+};
+
+export const getTagIdsFromReferences = (references: SavedObjectReferenceLike[]): string[] => {
+ return references.filter((ref) => ref.type === tagSavedObjectTypeName).map(({ id }) => id);
+};
+
+export const tagIdToReference = (tagId: string): SavedObjectReference => ({
+ type: tagSavedObjectTypeName,
+ id: tagId,
+ name: `tag-ref-${tagId}`,
+});
+
+export const updateTagsReferences = (
+ references: SavedObjectReference[],
+ newTagIds: string[]
+): SavedObjectReference[] => {
+ return [
+ ...references.filter(({ type }) => type !== tagSavedObjectTypeName),
+ ...newTagIds.map(tagIdToReference),
+ ];
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/config.ts b/x-pack/plugins/saved_objects_tagging/server/config.ts
new file mode 100644
index 0000000000000..88c8eae384cfd
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/config.ts
@@ -0,0 +1,22 @@
+/*
+ * 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, TypeOf } from '@kbn/config-schema';
+import { PluginConfigDescriptor } from 'kibana/server';
+
+const configSchema = schema.object({
+ enabled: schema.boolean({ defaultValue: true }),
+ cache_refresh_interval: schema.duration({ defaultValue: '15m' }),
+});
+
+export type SavedObjectsTaggingConfigType = TypeOf;
+
+export const config: PluginConfigDescriptor = {
+ schema: configSchema,
+ exposeToBrowser: {
+ cache_refresh_interval: true,
+ },
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/features.ts b/x-pack/plugins/saved_objects_tagging/server/features.ts
new file mode 100644
index 0000000000000..cb6ea335a17ba
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/features.ts
@@ -0,0 +1,47 @@
+/*
+ * 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 { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server';
+import { KibanaFeatureConfig } from '../../features/server';
+import { tagSavedObjectTypeName, tagManagementSectionId, tagFeatureId } from '../common/constants';
+
+export const savedObjectsTaggingFeature: KibanaFeatureConfig = {
+ id: tagFeatureId,
+ name: i18n.translate('xpack.savedObjectsTagging.feature.featureName', {
+ defaultMessage: 'Tag Management',
+ }),
+ category: DEFAULT_APP_CATEGORIES.management,
+ order: 1800,
+ app: [],
+ management: {
+ kibana: [tagManagementSectionId],
+ },
+ privileges: {
+ all: {
+ savedObject: {
+ all: [tagSavedObjectTypeName],
+ read: [],
+ },
+ api: [],
+ management: {
+ kibana: [tagManagementSectionId],
+ },
+ ui: ['view', 'create', 'edit', 'delete', 'assign'],
+ },
+ read: {
+ savedObject: {
+ all: [],
+ read: [tagSavedObjectTypeName],
+ },
+ management: {
+ kibana: [tagManagementSectionId],
+ },
+ api: [],
+ ui: ['view', 'assign'],
+ },
+ },
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/index.ts b/x-pack/plugins/saved_objects_tagging/server/index.ts
new file mode 100644
index 0000000000000..3b1d7a1ba6f27
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/index.ts
@@ -0,0 +1,13 @@
+/*
+ * 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 { PluginInitializerContext } from '../../../../src/core/server';
+import { SavedObjectTaggingPlugin } from './plugin';
+
+export { config } from './config';
+
+export const plugin = (initializerContext: PluginInitializerContext) =>
+ new SavedObjectTaggingPlugin(initializerContext);
diff --git a/x-pack/plugins/saved_objects_tagging/server/plugin.test.mocks.ts b/x-pack/plugins/saved_objects_tagging/server/plugin.test.mocks.ts
new file mode 100644
index 0000000000000..1223b1ec20389
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/plugin.test.mocks.ts
@@ -0,0 +1,10 @@
+/*
+ * 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.
+ */
+
+export const registerRoutesMock = jest.fn();
+jest.doMock('./routes', () => ({
+ registerRoutes: registerRoutesMock,
+}));
diff --git a/x-pack/plugins/saved_objects_tagging/server/plugin.test.ts b/x-pack/plugins/saved_objects_tagging/server/plugin.test.ts
new file mode 100644
index 0000000000000..1a3e4071f5e09
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/plugin.test.ts
@@ -0,0 +1,47 @@
+/*
+ * 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 { registerRoutesMock } from './plugin.test.mocks';
+
+import { coreMock } from '../../../../src/core/server/mocks';
+import { featuresPluginMock } from '../../features/server/mocks';
+import { SavedObjectTaggingPlugin } from './plugin';
+import { savedObjectsTaggingFeature } from './features';
+
+describe('SavedObjectTaggingPlugin', () => {
+ let plugin: SavedObjectTaggingPlugin;
+ let featuresPluginSetup: ReturnType;
+
+ beforeEach(() => {
+ plugin = new SavedObjectTaggingPlugin(coreMock.createPluginInitializerContext());
+ featuresPluginSetup = featuresPluginMock.createSetup();
+ });
+
+ describe('#setup', () => {
+ it('registers routes', async () => {
+ await plugin.setup(coreMock.createSetup(), { features: featuresPluginSetup });
+ expect(registerRoutesMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('registers the globalSearch route handler context', async () => {
+ const coreSetup = coreMock.createSetup();
+ await plugin.setup(coreSetup, { features: featuresPluginSetup });
+ expect(coreSetup.http.registerRouteHandlerContext).toHaveBeenCalledTimes(1);
+ expect(coreSetup.http.registerRouteHandlerContext).toHaveBeenCalledWith(
+ 'tags',
+ expect.any(Function)
+ );
+ });
+
+ it('registers the `savedObjectsTagging` feature', async () => {
+ await plugin.setup(coreMock.createSetup(), { features: featuresPluginSetup });
+ expect(featuresPluginSetup.registerKibanaFeature).toHaveBeenCalledTimes(1);
+ expect(featuresPluginSetup.registerKibanaFeature).toHaveBeenCalledWith(
+ savedObjectsTaggingFeature
+ );
+ });
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/server/plugin.ts b/x-pack/plugins/saved_objects_tagging/server/plugin.ts
new file mode 100644
index 0000000000000..8347fb1f8ef20
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/plugin.ts
@@ -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 { CoreSetup, CoreStart, PluginInitializerContext, Plugin } from 'src/core/server';
+import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';
+import { savedObjectsTaggingFeature } from './features';
+import { tagType } from './saved_objects';
+import { ITagsRequestHandlerContext } from './types';
+import { registerRoutes } from './routes';
+import { TagsRequestHandlerContext } from './request_handler_context';
+
+interface SetupDeps {
+ features: FeaturesPluginSetup;
+}
+
+export class SavedObjectTaggingPlugin implements Plugin<{}, {}, SetupDeps, {}> {
+ constructor(context: PluginInitializerContext) {}
+
+ public setup({ savedObjects, http }: CoreSetup, { features }: SetupDeps) {
+ savedObjects.registerType(tagType);
+
+ const router = http.createRouter();
+ registerRoutes({ router });
+
+ http.registerRouteHandlerContext(
+ 'tags',
+ async (context, req, res): Promise => {
+ return new TagsRequestHandlerContext(context.core);
+ }
+ );
+
+ features.registerKibanaFeature(savedObjectsTaggingFeature);
+
+ return {};
+ }
+
+ public start(core: CoreStart) {
+ return {};
+ }
+}
diff --git a/x-pack/plugins/saved_objects_tagging/server/request_handler_context.ts b/x-pack/plugins/saved_objects_tagging/server/request_handler_context.ts
new file mode 100644
index 0000000000000..08514a32d3e0c
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/request_handler_context.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 type { RequestHandlerContext } from 'src/core/server';
+import { ITagsClient } from '../common/types';
+import { ITagsRequestHandlerContext } from './types';
+import { TagsClient } from './tags';
+
+export class TagsRequestHandlerContext implements ITagsRequestHandlerContext {
+ #client?: ITagsClient;
+
+ constructor(private readonly coreContext: RequestHandlerContext['core']) {}
+
+ public get tagsClient() {
+ if (this.#client == null) {
+ this.#client = new TagsClient({ client: this.coreContext.savedObjects.client });
+ }
+ return this.#client;
+ }
+}
diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/create_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/create_tag.ts
new file mode 100644
index 0000000000000..2db9ed33972fe
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/routes/create_tag.ts
@@ -0,0 +1,44 @@
+/*
+ * 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';
+import { TagValidationError } from '../tags';
+
+export const registerCreateTagRoute = (router: IRouter) => {
+ router.post(
+ {
+ path: '/api/saved_objects_tagging/tags/create',
+ validate: {
+ body: schema.object({
+ name: schema.string(),
+ description: schema.string(),
+ color: schema.string(),
+ }),
+ },
+ },
+ router.handleLegacyErrors(async (ctx, req, res) => {
+ try {
+ const tag = await ctx.tags!.tagsClient.create(req.body);
+ return res.ok({
+ body: {
+ tag,
+ },
+ });
+ } catch (e) {
+ if (e instanceof TagValidationError) {
+ return res.badRequest({
+ body: {
+ message: e.message,
+ attributes: e.validation,
+ },
+ });
+ }
+ throw e;
+ }
+ })
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/delete_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/delete_tag.ts
new file mode 100644
index 0000000000000..84f798063555e
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/routes/delete_tag.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 registerDeleteTagRoute = (router: IRouter) => {
+ router.delete(
+ {
+ path: '/api/saved_objects_tagging/tags/{id}',
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ },
+ },
+ router.handleLegacyErrors(async (ctx, req, res) => {
+ const { id } = req.params;
+ await ctx.tags!.tagsClient.delete(id);
+ return res.ok();
+ })
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/get_all_tags.ts b/x-pack/plugins/saved_objects_tagging/server/routes/get_all_tags.ts
new file mode 100644
index 0000000000000..cbc43d66f0ecc
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/routes/get_all_tags.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 { IRouter } from 'src/core/server';
+
+export const registerGetAllTagsRoute = (router: IRouter) => {
+ router.get(
+ {
+ path: '/api/saved_objects_tagging/tags',
+ validate: {},
+ },
+ router.handleLegacyErrors(async (ctx, req, res) => {
+ const tags = await ctx.tags!.tagsClient.getAll();
+ return res.ok({
+ body: {
+ tags,
+ },
+ });
+ })
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/get_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/get_tag.ts
new file mode 100644
index 0000000000000..559c5ed2d8dd1
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/routes/get_tag.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 registerGetTagRoute = (router: IRouter) => {
+ router.get(
+ {
+ path: '/api/saved_objects_tagging/tags/{id}',
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ },
+ },
+ router.handleLegacyErrors(async (ctx, req, res) => {
+ const { id } = req.params;
+ const tag = await ctx.tags!.tagsClient.get(id);
+ return res.ok({
+ body: {
+ tag,
+ },
+ });
+ })
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/index.ts b/x-pack/plugins/saved_objects_tagging/server/routes/index.ts
new file mode 100644
index 0000000000000..9519f54e01693
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/routes/index.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 { IRouter } from 'src/core/server';
+import { registerCreateTagRoute } from './create_tag';
+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';
+
+export const registerRoutes = ({ router }: { router: IRouter }) => {
+ // public API
+ registerCreateTagRoute(router);
+ registerUpdateTagRoute(router);
+ registerDeleteTagRoute(router);
+ registerGetAllTagsRoute(router);
+ registerGetTagRoute(router);
+ // internal API
+ registerInternalFindTagsRoute(router);
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts b/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts
new file mode 100644
index 0000000000000..2b7515a93acab
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/routes/internal/find_tags.ts
@@ -0,0 +1,51 @@
+/*
+ * 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';
+import { tagSavedObjectTypeName } from '../../../common/constants';
+import { TagAttributes } from '../../../common/types';
+import { savedObjectToTag } from '../../tags';
+import { addConnectionCount } from '../lib';
+
+export const registerInternalFindTagsRoute = (router: IRouter) => {
+ router.get(
+ {
+ path: '/internal/saved_objects_tagging/tags/_find',
+ validate: {
+ query: schema.object({
+ perPage: schema.number({ min: 0, defaultValue: 20 }),
+ page: schema.number({ min: 0, defaultValue: 1 }),
+ search: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ router.handleLegacyErrors(async (ctx, req, res) => {
+ const { query } = req;
+ const { client, typeRegistry } = ctx.core.savedObjects;
+
+ const findResponse = await client.find({
+ page: query.page,
+ perPage: query.perPage,
+ search: query.search,
+ type: [tagSavedObjectTypeName],
+ searchFields: ['title', 'description'],
+ });
+
+ const tags = findResponse.saved_objects.map(savedObjectToTag);
+ const allTypes = typeRegistry.getAllTypes().map((type) => type.name);
+
+ const tagsWithConnections = await addConnectionCount(tags, allTypes, client);
+
+ return res.ok({
+ body: {
+ tags: tagsWithConnections,
+ total: findResponse.total,
+ },
+ });
+ })
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/internal/index.ts b/x-pack/plugins/saved_objects_tagging/server/routes/internal/index.ts
new file mode 100644
index 0000000000000..9d427cfe5831c
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/routes/internal/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { registerInternalFindTagsRoute } from './find_tags';
diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/lib/get_connection_count.ts b/x-pack/plugins/saved_objects_tagging/server/routes/lib/get_connection_count.ts
new file mode 100644
index 0000000000000..4f77b8ab15fbb
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/routes/lib/get_connection_count.ts
@@ -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 { SavedObjectsClientContract, SavedObjectsFindOptionsReference } from 'src/core/server';
+import { tagSavedObjectTypeName } from '../../../common/constants';
+import { Tag, TagWithRelations } from '../../../common/types';
+
+export const addConnectionCount = async (
+ tags: Tag[],
+ targetTypes: string[],
+ client: SavedObjectsClientContract
+): Promise => {
+ const ids = new Set(tags.map((tag) => tag.id));
+ const counts: Map = new Map(tags.map((tag) => [tag.id, 0]));
+
+ const references: SavedObjectsFindOptionsReference[] = tags.map(({ id }) => ({
+ type: 'tag',
+ id,
+ }));
+
+ const allResults = await client.find({
+ type: targetTypes,
+ page: 1,
+ perPage: 10000,
+ hasReference: references,
+ hasReferenceOperator: 'OR',
+ });
+ allResults.saved_objects.forEach((obj) => {
+ obj.references.forEach((ref) => {
+ if (ref.type === tagSavedObjectTypeName && ids.has(ref.id)) {
+ counts.set(ref.id, counts.get(ref.id)! + 1);
+ }
+ });
+ });
+
+ return tags.map((tag) => ({
+ ...tag,
+ relationCount: counts.get(tag.id)!,
+ }));
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/lib/index.ts b/x-pack/plugins/saved_objects_tagging/server/routes/lib/index.ts
new file mode 100644
index 0000000000000..c860e3eeb9666
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/routes/lib/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { addConnectionCount } from './get_connection_count';
diff --git a/x-pack/plugins/saved_objects_tagging/server/routes/update_tag.ts b/x-pack/plugins/saved_objects_tagging/server/routes/update_tag.ts
new file mode 100644
index 0000000000000..2377e86aca3a1
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/routes/update_tag.ts
@@ -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 { schema } from '@kbn/config-schema';
+import { IRouter } from 'src/core/server';
+import { TagValidationError } from '../tags';
+
+export const registerUpdateTagRoute = (router: IRouter) => {
+ router.post(
+ {
+ path: '/api/saved_objects_tagging/tags/{id}',
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ body: schema.object({
+ name: schema.string(),
+ description: schema.string(),
+ color: schema.string(),
+ }),
+ },
+ },
+ router.handleLegacyErrors(async (ctx, req, res) => {
+ const { id } = req.params;
+ try {
+ const tag = await ctx.tags!.tagsClient.update(id, req.body);
+ return res.ok({
+ body: {
+ tag,
+ },
+ });
+ } catch (e) {
+ if (e instanceof TagValidationError) {
+ return res.badRequest({
+ body: {
+ message: e.message,
+ attributes: e.validation,
+ },
+ });
+ }
+ throw e;
+ }
+ })
+ );
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/saved_objects/index.ts b/x-pack/plugins/saved_objects_tagging/server/saved_objects/index.ts
new file mode 100644
index 0000000000000..2a9e1c21a1169
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/saved_objects/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { tagType } from './tag';
diff --git a/x-pack/plugins/saved_objects_tagging/server/saved_objects/tag.ts b/x-pack/plugins/saved_objects_tagging/server/saved_objects/tag.ts
new file mode 100644
index 0000000000000..d5dee35b9476e
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/saved_objects/tag.ts
@@ -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 { SavedObject, SavedObjectsType } from 'src/core/server';
+import { tagSavedObjectTypeName, TagAttributes } from '../../common';
+
+export const tagType: SavedObjectsType = {
+ name: tagSavedObjectTypeName,
+ hidden: false,
+ namespaceType: 'single',
+ mappings: {
+ properties: {
+ name: {
+ type: 'text',
+ },
+ description: {
+ type: 'text',
+ },
+ color: {
+ type: 'text',
+ },
+ },
+ },
+ management: {
+ importableAndExportable: true,
+ defaultSearchField: 'name',
+ icon: 'tag',
+ getTitle: (obj: SavedObject) => obj.attributes.name,
+ },
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/errors.test.ts b/x-pack/plugins/saved_objects_tagging/server/tags/errors.test.ts
new file mode 100644
index 0000000000000..a120b2f5ed557
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/tags/errors.test.ts
@@ -0,0 +1,31 @@
+/*
+ * 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 { TagValidation } from '../../common/validation';
+import { TagValidationError } from './errors';
+
+const createValidation = (errors: TagValidation['errors'] = {}): TagValidation => ({
+ valid: Object.keys(errors).length === 0,
+ warnings: [],
+ errors,
+});
+
+describe('TagValidationError', () => {
+ it('is assignable to its instances', () => {
+ // this test is here to ensure the `Object.setPrototypeOf` constructor workaround for TS is not removed.
+ const error = new TagValidationError('validation error', createValidation());
+
+ expect(error instanceof TagValidationError).toBe(true);
+ });
+
+ it('allow access to the underlying validation', () => {
+ const validation = createValidation();
+
+ const error = new TagValidationError('validation error', createValidation());
+
+ expect(error.validation).toStrictEqual(validation);
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/errors.ts b/x-pack/plugins/saved_objects_tagging/server/tags/errors.ts
new file mode 100644
index 0000000000000..ee1f247dcf56b
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/tags/errors.ts
@@ -0,0 +1,18 @@
+/*
+ * 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 { TagValidation } from '../../common';
+
+/**
+ * Error returned from {@link TagsClient#create} or {@link TagsClient#update} when tag
+ * validation failed.
+ */
+export class TagValidationError extends Error {
+ constructor(message: string, public readonly validation: TagValidation) {
+ super(message);
+ Object.setPrototypeOf(this, TagValidationError.prototype);
+ }
+}
diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/index.ts b/x-pack/plugins/saved_objects_tagging/server/tags/index.ts
new file mode 100644
index 0000000000000..4dacf94af73ad
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/tags/index.ts
@@ -0,0 +1,9 @@
+/*
+ * 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.
+ */
+
+export { TagsClient } from './tags_client';
+export { TagValidationError } from './errors';
+export { savedObjectToTag } from './utils';
diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.mock.ts b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.mock.ts
new file mode 100644
index 0000000000000..a5eafb127e5c7
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.mock.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 { ITagsClient } from '../../common/types';
+
+const createClientMock = () => {
+ const mock: jest.Mocked = {
+ create: jest.fn(),
+ get: jest.fn(),
+ getAll: jest.fn(),
+ delete: jest.fn(),
+ update: jest.fn(),
+ };
+
+ return mock;
+};
+
+export const tagsClientMock = {
+ create: createClientMock,
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.mocks.ts b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.mocks.ts
new file mode 100644
index 0000000000000..c8c77164131cd
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.mocks.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export const validateTagMock = jest.fn();
+
+jest.doMock('./validate_tag', () => ({
+ validateTag: validateTagMock,
+}));
diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.ts
new file mode 100644
index 0000000000000..7e656acb0204c
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.test.ts
@@ -0,0 +1,241 @@
+/*
+ * 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 { validateTagMock } from './tags_client.test.mocks';
+
+import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
+import { TagAttributes, TagSavedObject } from '../../common/types';
+import { TagValidation } from '../../common/validation';
+import { TagsClient } from './tags_client';
+import { TagValidationError } from './errors';
+
+const createAttributes = (parts: Partial = {}): TagAttributes => ({
+ name: 'a-tag',
+ description: 'some-desc',
+ color: '#FF00CC',
+ ...parts,
+});
+
+const createTagSavedObject = (
+ id: string = 'tag-id',
+ attributes: TagAttributes = createAttributes()
+): TagSavedObject => ({
+ id,
+ attributes,
+ type: 'tag',
+ references: [],
+});
+
+const createValidation = (errors: TagValidation['errors'] = {}): TagValidation => ({
+ valid: Object.keys(errors).length === 0,
+ warnings: [],
+ errors,
+});
+
+describe('TagsClient', () => {
+ let soClient: ReturnType;
+ let tagsClient: TagsClient;
+
+ beforeEach(() => {
+ soClient = savedObjectsClientMock.create();
+ tagsClient = new TagsClient({ client: soClient });
+
+ validateTagMock.mockReturnValue({ valid: true });
+ });
+
+ describe('#create', () => {
+ beforeEach(() => {
+ soClient.create.mockResolvedValue(createTagSavedObject());
+ });
+
+ it('calls `soClient.create` with the correct parameters', async () => {
+ const attributes = createAttributes();
+
+ await tagsClient.create(attributes);
+
+ expect(soClient.create).toHaveBeenCalledTimes(1);
+ expect(soClient.create).toHaveBeenCalledWith('tag', attributes);
+ });
+
+ it('converts the object returned from the soClient to a `Tag`', async () => {
+ const id = 'some-id';
+ const attributes = createAttributes();
+ soClient.create.mockResolvedValue(createTagSavedObject(id, attributes));
+
+ const tag = await tagsClient.create(attributes);
+ expect(tag).toEqual({
+ id,
+ ...attributes,
+ });
+ });
+
+ it('returns a `TagValidationError` if attributes validation fails', async () => {
+ const validation = createValidation({
+ name: 'Invalid name',
+ });
+ validateTagMock.mockReturnValue(validation);
+
+ await expect(tagsClient.create(createAttributes())).rejects.toThrowError(TagValidationError);
+ });
+
+ it('does not call `soClient.create` if attributes validation fails', async () => {
+ expect.assertions(1);
+
+ const validation = createValidation({
+ name: 'Invalid name',
+ });
+ validateTagMock.mockReturnValue(validation);
+
+ try {
+ await tagsClient.create(createAttributes());
+ } catch (e) {
+ expect(soClient.create).not.toHaveBeenCalled();
+ }
+ });
+ });
+
+ describe('#update', () => {
+ const tagId = 'some-id';
+
+ beforeEach(() => {
+ soClient.update.mockResolvedValue(createTagSavedObject());
+ });
+
+ it('calls `soClient.update` with the correct parameters', async () => {
+ const attributes = createAttributes();
+
+ await tagsClient.update(tagId, attributes);
+
+ expect(soClient.update).toHaveBeenCalledTimes(1);
+ expect(soClient.update).toHaveBeenCalledWith('tag', tagId, attributes);
+ });
+
+ it('converts the object returned from the soClient to a `Tag`', async () => {
+ const attributes = createAttributes();
+ soClient.update.mockResolvedValue(createTagSavedObject(tagId, attributes));
+
+ const tag = await tagsClient.update(tagId, attributes);
+ expect(tag).toEqual({
+ id: tagId,
+ ...attributes,
+ });
+ });
+
+ it('returns a `TagValidationError` if attributes validation fails', async () => {
+ const validation = createValidation({
+ name: 'Invalid name',
+ });
+ validateTagMock.mockReturnValue(validation);
+
+ await expect(tagsClient.update(tagId, createAttributes())).rejects.toThrowError(
+ TagValidationError
+ );
+ });
+
+ it('does not call `soClient.create` if attributes validation fails', async () => {
+ expect.assertions(1);
+
+ const validation = createValidation({
+ name: 'Invalid name',
+ });
+ validateTagMock.mockReturnValue(validation);
+
+ try {
+ await tagsClient.update(tagId, createAttributes());
+ } catch (e) {
+ expect(soClient.update).not.toHaveBeenCalled();
+ }
+ });
+ });
+
+ describe('#get', () => {
+ const tagId = 'some-id';
+ const tagObject = createTagSavedObject(tagId);
+
+ beforeEach(() => {
+ soClient.get.mockResolvedValue(tagObject);
+ });
+
+ it('calls `soClient.get` with the correct parameters', async () => {
+ await tagsClient.get(tagId);
+
+ expect(soClient.get).toHaveBeenCalledTimes(1);
+ expect(soClient.get).toHaveBeenCalledWith('tag', tagId);
+ });
+
+ it('converts the object returned from the soClient to a `Tag`', async () => {
+ const tag = await tagsClient.get(tagId);
+ expect(tag).toEqual({
+ id: tagId,
+ ...tagObject.attributes,
+ });
+ });
+ });
+ describe('#getAll', () => {
+ const tags = [
+ createTagSavedObject('tag-1'),
+ createTagSavedObject('tag-2'),
+ createTagSavedObject('tag-3'),
+ ];
+
+ beforeEach(() => {
+ soClient.find.mockResolvedValue({
+ saved_objects: tags.map((tag) => ({ ...tag, score: 1 })),
+ total: 3,
+ per_page: 1000,
+ page: 0,
+ });
+ });
+
+ it('calls `soClient.find` with the correct parameters', async () => {
+ await tagsClient.getAll();
+
+ expect(soClient.find).toHaveBeenCalledTimes(1);
+ expect(soClient.find).toHaveBeenCalledWith({
+ type: 'tag',
+ perPage: 10000,
+ });
+ });
+
+ it('converts the objects returned from the soClient to tags', async () => {
+ const returnedTags = await tagsClient.getAll();
+ expect(returnedTags).toEqual(tags.map((tag) => ({ id: tag.id, ...tag.attributes })));
+ });
+ });
+ describe('#delete', () => {
+ const tagId = 'tag-id';
+
+ it('calls `soClient.delete` with the correct parameters', async () => {
+ await tagsClient.delete(tagId);
+
+ expect(soClient.delete).toHaveBeenCalledTimes(1);
+ expect(soClient.delete).toHaveBeenCalledWith('tag', tagId);
+ });
+
+ it('calls `soClient.removeReferencesTo` with the correct parameters', async () => {
+ await tagsClient.delete(tagId);
+
+ expect(soClient.removeReferencesTo).toHaveBeenCalledTimes(1);
+ expect(soClient.removeReferencesTo).toHaveBeenCalledWith('tag', tagId);
+ });
+
+ it('calls `soClient.removeReferencesTo` before `soClient.delete`', async () => {
+ await tagsClient.delete(tagId);
+
+ expect(soClient.removeReferencesTo.mock.invocationCallOrder[0]).toBeLessThan(
+ soClient.delete.mock.invocationCallOrder[0]
+ );
+ });
+
+ it('does not calls `soClient.delete` if `soClient.removeReferencesTo` throws', async () => {
+ soClient.removeReferencesTo.mockRejectedValue(new Error('something went wrong'));
+
+ await expect(tagsClient.delete(tagId)).rejects.toThrowError();
+
+ expect(soClient.delete).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.ts
new file mode 100644
index 0000000000000..ef4ad6f128346
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/tags/tags_client.ts
@@ -0,0 +1,66 @@
+/*
+ * 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 { SavedObjectsClientContract } from 'src/core/server';
+import { TagSavedObject, TagAttributes, ITagsClient } from '../../common/types';
+import { tagSavedObjectTypeName } from '../../common/constants';
+import { TagValidationError } from './errors';
+import { validateTag } from './validate_tag';
+import { savedObjectToTag } from './utils';
+
+interface TagsClientOptions {
+ client: SavedObjectsClientContract;
+}
+
+export class TagsClient implements ITagsClient {
+ private readonly soClient: SavedObjectsClientContract;
+ private readonly type = tagSavedObjectTypeName;
+
+ constructor({ client }: TagsClientOptions) {
+ this.soClient = client;
+ }
+
+ public async create(attributes: TagAttributes) {
+ const validation = validateTag(attributes);
+ if (!validation.valid) {
+ throw new TagValidationError('Error validating tag attributes', validation);
+ }
+ const raw = await this.soClient.create(this.type, attributes);
+ return savedObjectToTag(raw);
+ }
+
+ public async update(id: string, attributes: TagAttributes) {
+ const validation = validateTag(attributes);
+ if (!validation.valid) {
+ throw new TagValidationError('Error validating tag attributes', validation);
+ }
+ const raw = await this.soClient.update(this.type, id, attributes);
+ return savedObjectToTag(raw as TagSavedObject); // all attributes are updated, this is not a partial
+ }
+
+ public async get(id: string) {
+ const raw = await this.soClient.get(this.type, id);
+ return savedObjectToTag(raw);
+ }
+
+ public async getAll() {
+ const result = await this.soClient.find({
+ type: this.type,
+ perPage: 10000,
+ });
+
+ return result.saved_objects.map(savedObjectToTag);
+ }
+
+ public async delete(id: string) {
+ // `removeReferencesTo` security check is the same as a `delete` operation's, so we can use the scoped client here.
+ // If that was to change, we would need to use the internal client instead. A FTR test is ensuring
+ // that this behave properly even with only 'tag' SO type write permission.
+ await this.soClient.removeReferencesTo(this.type, id);
+ // deleting the tag after reference removal in case of failure during the first call.
+ await this.soClient.delete(this.type, id);
+ }
+}
diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/utils.ts b/x-pack/plugins/saved_objects_tagging/server/tags/utils.ts
new file mode 100644
index 0000000000000..bd9dece0eaf61
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/tags/utils.ts
@@ -0,0 +1,14 @@
+/*
+ * 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 { Tag, TagSavedObject } from '../../common/types';
+
+export const savedObjectToTag = (savedObject: TagSavedObject): Tag => {
+ return {
+ id: savedObject.id,
+ ...savedObject.attributes,
+ };
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.mocks.ts b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.mocks.ts
new file mode 100644
index 0000000000000..62b6b203f42cf
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.mocks.ts
@@ -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.
+ */
+
+export const validateTagNameMock = jest.fn();
+export const validateTagColorMock = jest.fn();
+export const validateTagDescriptionMock = jest.fn();
+
+jest.doMock('../../common/validation', () => ({
+ validateTagName: validateTagNameMock,
+ validateTagColor: validateTagColorMock,
+ validateTagDescription: validateTagDescriptionMock,
+}));
diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.ts b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.ts
new file mode 100644
index 0000000000000..2e8201d560245
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.test.ts
@@ -0,0 +1,119 @@
+/*
+ * 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 {
+ validateTagColorMock,
+ validateTagNameMock,
+ validateTagDescriptionMock,
+} from './validate_tag.test.mocks';
+
+import { TagAttributes } from '../../common/types';
+import { validateTag } from './validate_tag';
+
+const createAttributes = (parts: Partial = {}): TagAttributes => ({
+ name: 'a-tag',
+ description: 'some-desc',
+ color: '#FF00CC',
+ ...parts,
+});
+
+describe('validateTag', () => {
+ beforeEach(() => {
+ validateTagNameMock.mockReset();
+ validateTagColorMock.mockReset();
+ validateTagDescriptionMock.mockReset();
+ });
+
+ it('calls `validateTagName` with attributes.name', () => {
+ const attributes = createAttributes();
+
+ validateTag(attributes);
+
+ expect(validateTagNameMock).toHaveBeenCalledTimes(1);
+ expect(validateTagNameMock).toHaveBeenCalledWith(attributes.name);
+ });
+
+ it('returns the error from `validateTagName` in `errors.name`', () => {
+ const nameError = 'invalid name';
+ const attributes = createAttributes();
+ validateTagNameMock.mockReturnValue(nameError);
+
+ const validation = validateTag(attributes);
+
+ expect(validation.errors.name).toBe(nameError);
+ });
+
+ it('calls `validateTagColor` with attributes.color', () => {
+ const attributes = createAttributes();
+
+ validateTag(attributes);
+
+ expect(validateTagColorMock).toHaveBeenCalledTimes(1);
+ expect(validateTagColorMock).toHaveBeenCalledWith(attributes.color);
+ });
+
+ it('returns the error from `validateTagColor` in `errors.color`', () => {
+ const nameError = 'invalid color';
+ const attributes = createAttributes();
+ validateTagColorMock.mockReturnValue(nameError);
+
+ const validation = validateTag(attributes);
+
+ expect(validation.errors.color).toBe(nameError);
+ });
+
+ it('returns `valid: true` if no field has error', () => {
+ const attributes = createAttributes();
+ validateTagNameMock.mockReturnValue(undefined);
+ validateTagColorMock.mockReturnValue(undefined);
+
+ const validation = validateTag(attributes);
+ expect(validation.valid).toBe(true);
+ });
+
+ it('calls `validateTagDescription` with attributes.description', () => {
+ const attributes = createAttributes();
+
+ validateTag(attributes);
+
+ expect(validateTagDescriptionMock).toHaveBeenCalledTimes(1);
+ expect(validateTagDescriptionMock).toHaveBeenCalledWith(attributes.description);
+ });
+
+ it('returns the error from `validateTagDescription` in `errors.description`', () => {
+ const descError = 'invalid description';
+ const attributes = createAttributes();
+ validateTagDescriptionMock.mockReturnValue(descError);
+
+ const validation = validateTag(attributes);
+
+ expect(validation.errors.description).toBe(descError);
+ });
+
+ it('returns `valid: false` if any field has error', () => {
+ const attributes = createAttributes();
+ validateTagNameMock.mockReturnValue('invalid name');
+ validateTagColorMock.mockReturnValue(undefined);
+ validateTagDescriptionMock.mockReturnValue(undefined);
+
+ let validation = validateTag(attributes);
+ expect(validation.valid).toBe(false);
+
+ validateTagNameMock.mockReturnValue(undefined);
+ validateTagColorMock.mockReturnValue('invalid color');
+ validateTagDescriptionMock.mockReturnValue(undefined);
+
+ validation = validateTag(attributes);
+ expect(validation.valid).toBe(false);
+
+ validateTagNameMock.mockReturnValue(undefined);
+ validateTagColorMock.mockReturnValue(undefined);
+ validateTagDescriptionMock.mockReturnValue('invalid desc');
+
+ validation = validateTag(attributes);
+ expect(validation.valid).toBe(false);
+ });
+});
diff --git a/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.ts b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.ts
new file mode 100644
index 0000000000000..e49c4cee504b8
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/tags/validate_tag.ts
@@ -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 { TagAttributes } from '../../common/types';
+import {
+ TagValidation,
+ validateTagColor,
+ validateTagName,
+ validateTagDescription,
+} from '../../common/validation';
+
+export const validateTag = (attributes: TagAttributes): TagValidation => {
+ const validation: TagValidation = {
+ valid: true,
+ warnings: [],
+ errors: {},
+ };
+
+ validation.errors.name = validateTagName(attributes.name);
+ validation.errors.color = validateTagColor(attributes.color);
+ validation.errors.description = validateTagDescription(attributes.description);
+
+ Object.values(validation.errors).forEach((error) => {
+ if (error) {
+ validation.valid = false;
+ }
+ });
+
+ return validation;
+};
diff --git a/x-pack/plugins/saved_objects_tagging/server/types.ts b/x-pack/plugins/saved_objects_tagging/server/types.ts
new file mode 100644
index 0000000000000..9997be0c3cb22
--- /dev/null
+++ b/x-pack/plugins/saved_objects_tagging/server/types.ts
@@ -0,0 +1,17 @@
+/*
+ * 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 { ITagsClient } from '../common/types';
+
+export interface ITagsRequestHandlerContext {
+ tagsClient: ITagsClient;
+}
+
+declare module 'src/core/server' {
+ interface RequestHandlerContext {
+ tags?: ITagsRequestHandlerContext;
+ }
+}
diff --git a/x-pack/plugins/security/server/audit/audit_events.test.ts b/x-pack/plugins/security/server/audit/audit_events.test.ts
index f153b9efb9d43..1713badede2f7 100644
--- a/x-pack/plugins/security/server/audit/audit_events.test.ts
+++ b/x-pack/plugins/security/server/audit/audit_events.test.ts
@@ -105,6 +105,34 @@ describe('#savedObjectEvent', () => {
}
`);
});
+
+ test('creates event with `success` outcome for `REMOVE_REFERENCES` action', () => {
+ expect(
+ savedObjectEvent({
+ action: SavedObjectAction.REMOVE_REFERENCES,
+ savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' },
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "error": undefined,
+ "event": Object {
+ "action": "saved_object_remove_references",
+ "category": "database",
+ "outcome": "success",
+ "type": "change",
+ },
+ "kibana": Object {
+ "add_to_spaces": undefined,
+ "delete_from_spaces": undefined,
+ "saved_object": Object {
+ "id": "SAVED_OBJECT_ID",
+ "type": "dashboard",
+ },
+ },
+ "message": "User has removed references to dashboard [id=SAVED_OBJECT_ID]",
+ }
+ `);
+ });
});
describe('#userLoginEvent', () => {
diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts
index d91c18bf82e02..e3c1f95349c92 100644
--- a/x-pack/plugins/security/server/audit/audit_events.ts
+++ b/x-pack/plugins/security/server/audit/audit_events.ts
@@ -175,6 +175,7 @@ export enum SavedObjectAction {
FIND = 'saved_object_find',
ADD_TO_SPACES = 'saved_object_add_to_spaces',
DELETE_FROM_SPACES = 'saved_object_delete_from_spaces',
+ REMOVE_REFERENCES = 'saved_object_remove_references',
}
const eventVerbs = {
@@ -185,6 +186,11 @@ const eventVerbs = {
saved_object_find: ['access', 'accessing', 'accessed'],
saved_object_add_to_spaces: ['update', 'updating', 'updated'],
saved_object_delete_from_spaces: ['update', 'updating', 'updated'],
+ saved_object_remove_references: [
+ 'remove references to',
+ 'removing references to',
+ 'removed references to',
+ ],
};
const eventTypes = {
@@ -195,6 +201,7 @@ const eventTypes = {
saved_object_find: EventType.ACCESS,
saved_object_add_to_spaces: EventType.CHANGE,
saved_object_delete_from_spaces: EventType.CHANGE,
+ saved_object_remove_references: EventType.CHANGE,
};
export interface SavedObjectParams {
diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts
index 8136553e4a623..6b9592815dfc5 100644
--- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts
+++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts
@@ -1098,6 +1098,57 @@ describe('#update', () => {
});
});
+describe('#removeReferencesTo', () => {
+ const type = 'foo';
+ const id = `${type}-id`;
+ const namespace = 'some-ns';
+ const options = { namespace };
+
+ test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => {
+ await expectGeneralError(client.removeReferencesTo, { type, id, options });
+ });
+
+ test(`throws decorated ForbiddenError when unauthorized`, async () => {
+ await expectForbiddenError(
+ client.removeReferencesTo,
+ { type, id, options },
+ 'removeReferences'
+ );
+ });
+
+ test(`returns result of baseClient.removeReferencesTo when authorized`, async () => {
+ const apiCallReturnValue = Symbol();
+ clientOpts.baseClient.removeReferencesTo.mockReturnValue(apiCallReturnValue as any);
+
+ const result = await expectSuccess(
+ client.removeReferencesTo,
+ { type, id, options },
+ 'removeReferences'
+ );
+ expect(result).toBe(apiCallReturnValue);
+ });
+
+ test(`checks privileges for user, actions, and namespace`, async () => {
+ await expectPrivilegeCheck(client.removeReferencesTo, { type, id, options }, namespace);
+ });
+
+ test(`adds audit event when successful`, async () => {
+ const apiCallReturnValue = Symbol();
+ clientOpts.baseClient.removeReferencesTo.mockReturnValue(apiCallReturnValue as any);
+ await client.removeReferencesTo(type, id);
+
+ expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
+ expectAuditEvent('saved_object_remove_references', EventOutcome.UNKNOWN, { type, id });
+ });
+
+ test(`adds audit event when not successful`, async () => {
+ clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
+ await expect(() => client.removeReferencesTo(type, id)).rejects.toThrow();
+ expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
+ expectAuditEvent('saved_object_remove_references', EventOutcome.FAILURE, { type, id });
+ });
+});
+
describe('other', () => {
test(`assigns errors from constructor to .errors`, () => {
expect(client.errors).toBe(clientOpts.errors);
diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
index 85e8e21da81b0..2ef0cafcd6fdb 100644
--- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
+++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
@@ -5,6 +5,7 @@
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
import {
+ SavedObjectsAddToNamespacesOptions,
SavedObjectsBaseOptions,
SavedObjectsBulkCreateObject,
SavedObjectsBulkGetObject,
@@ -12,18 +13,23 @@ import {
SavedObjectsCheckConflictsObject,
SavedObjectsClientContract,
SavedObjectsCreateOptions,
+ SavedObjectsDeleteFromNamespacesOptions,
SavedObjectsFindOptions,
+ SavedObjectsRemoveReferencesToOptions,
SavedObjectsUpdateOptions,
- SavedObjectsAddToNamespacesOptions,
- SavedObjectsDeleteFromNamespacesOptions,
SavedObjectsUtils,
} from '../../../../../src/core/server';
import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants';
-import { SecurityAuditLogger } from '../audit';
+import {
+ AuditLogger,
+ EventOutcome,
+ SavedObjectAction,
+ savedObjectEvent,
+ SecurityAuditLogger,
+} from '../audit';
import { Actions, CheckSavedObjectsPrivileges } from '../authorization';
import { CheckPrivilegesResponse } from '../authorization/types';
import { SpacesService } from '../plugin';
-import { AuditLogger, EventOutcome, SavedObjectAction, savedObjectEvent } from '../audit';
interface SecureSavedObjectsClientWrapperOptions {
actions: Actions;
@@ -476,6 +482,39 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
return await this.redactSavedObjectsNamespaces(response);
}
+ public async removeReferencesTo(
+ type: string,
+ id: string,
+ options: SavedObjectsRemoveReferencesToOptions = {}
+ ) {
+ try {
+ const args = { type, id, options };
+ await this.ensureAuthorized(type, 'delete', options.namespace, {
+ args,
+ auditAction: 'removeReferences',
+ });
+ } catch (error) {
+ this.auditLogger.log(
+ savedObjectEvent({
+ action: SavedObjectAction.REMOVE_REFERENCES,
+ savedObject: { type, id },
+ error,
+ })
+ );
+ throw error;
+ }
+
+ this.auditLogger.log(
+ savedObjectEvent({
+ action: SavedObjectAction.REMOVE_REFERENCES,
+ savedObject: { type, id },
+ outcome: EventOutcome.UNKNOWN,
+ })
+ );
+
+ return await this.baseClient.removeReferencesTo(type, id, options);
+ }
+
private async checkPrivileges(
actions: string | string[],
namespaceOrNamespaces?: string | Array