[Dashboard] Inject / extract tag references (#214788)

Fixes #210619

## Summary

Provides a tags array on the request and response bodies of dashboards.

This allows consumers of the Dashboards HTTP API and internal RPC API to
add an array of tag names to the attributes in the body of create and
update endpoints. The dashboard server will be responsible for
converting the tag names into references in the saved object.

If, during creation or update, a tag name does not have a matching tag
saved object, a new tag saved object will be created. If the user lacks
permissions to manage tags, then an error will be logged in the server
and the tag will not be added to the dashboard.

The server also injects the tag references as an array of tag names in
the attributes of the response body of get and search endpoints of the
HTTP and RPC APIs.

For backwards compatibility in create and update endpoints, tags can
alternatively be specified in the `references` array in the options
instead of (or in addition to) the `attributes.tags` in the request
body. Similarly, for backwards compatibility, tag references are
returned in the `references` of the response body of get and search
endpoints.

Client-side tag handling is out of scope for this PR. Dashboards listing
page and dashboard settings continue to use the tag references and do
not use the `tags` attribute in the response.

For example:

Here's how we currently create a dashboard with tag references.
```
## Creating a dashboard with tag references
POST kbn:/api/dashboards/dashboard
{
  "attributes": {
    "title": "my tagged dashboard"
  },
  "references": [
    {
      "type": "tag",
      "id": "37aab5de-a34d-47cb-9aa5-9375d5db595f",
      "name": "tag-ref-37aab5de-a34d-47cb-9aa5-9375d5db595f"
    },
    {
      "type": "tag",
      "id": "5ed29bba-c14d-4302-9a8c-9602e40dbc2a",
      "name": "tag-ref-5ed29bba-c14d-4302-9a8c-9602e40dbc2a"
    },
    {
      "type": "tag",
      "id": "fc7890e8-c00f-44a1-88a2-250e4d27e61d",
      "name": "tag-ref-fc7890e8-c00f-44a1-88a2-250e4d27e61d"
    }
  ]
}
```

With this PR, creating a dashboard with tags is much simpler.

```
## Creating a dashboard with tag names
POST kbn:/api/dashboards/dashboard
{
  "attributes": {
    "title": "my tagged dashboard",
    "tags": [
      "boo",
      "foo",
      "bingo",
      "bongo"
    ]
  }
}

```

### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

### Identify risks

Does this PR introduce any risks? For example, consider risks like hard
to test bugs, performance regression, potential of data loss.

Describe the risk, its severity, and mitigation for each identified
risk. Invite stakeholders and evaluate how to proceed before merging.

- [ ] If there are more than one tag saved objects with the same name,
only one of the tag references will be added to the saved object when
creating a dashboard. Creating tags with duplicate names are not
permitted via the UI. But there is no such restrictions when creating
tags from imported saved objects. Having multiple tags with the same
name is an edge case that Kibana guards against with reasonable
restrictions, so I think we should not be too concerned about it.
- [ ] ...

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nick Peihl 2025-03-31 10:52:24 -04:00 committed by GitHub
parent 8728a23282
commit c4f7c649b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2893 additions and 135 deletions

View file

@ -6738,6 +6738,13 @@
"description": "A short description.",
"type": "string"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeRestore": {
"default": false,
"description": "Whether to restore time upon viewing this dashboard",
@ -7384,6 +7391,13 @@
],
"type": "object"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeFrom": {
"description": "An ISO string indicating when to restore time from",
"type": "string"
@ -8038,6 +8052,13 @@
],
"type": "object"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeFrom": {
"description": "An ISO string indicating when to restore time from",
"type": "string"
@ -8576,6 +8597,13 @@
],
"type": "object"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeFrom": {
"description": "An ISO string indicating when to restore time from",
"type": "string"
@ -9202,6 +9230,13 @@
],
"type": "object"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeFrom": {
"description": "An ISO string indicating when to restore time from",
"type": "string"
@ -9734,6 +9769,13 @@
],
"type": "object"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeFrom": {
"description": "An ISO string indicating when to restore time from",
"type": "string"

View file

@ -6738,6 +6738,13 @@
"description": "A short description.",
"type": "string"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeRestore": {
"default": false,
"description": "Whether to restore time upon viewing this dashboard",
@ -7384,6 +7391,13 @@
],
"type": "object"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeFrom": {
"description": "An ISO string indicating when to restore time from",
"type": "string"
@ -8038,6 +8052,13 @@
],
"type": "object"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeFrom": {
"description": "An ISO string indicating when to restore time from",
"type": "string"
@ -8576,6 +8597,13 @@
],
"type": "object"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeFrom": {
"description": "An ISO string indicating when to restore time from",
"type": "string"
@ -9202,6 +9230,13 @@
],
"type": "object"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeFrom": {
"description": "An ISO string indicating when to restore time from",
"type": "string"
@ -9734,6 +9769,13 @@
],
"type": "object"
},
"tags": {
"items": {
"description": "An array of tags applied to this dashboard",
"type": "string"
},
"type": "array"
},
"timeFrom": {
"description": "An ISO string indicating when to restore time from",
"type": "string"

View file

@ -6168,6 +6168,11 @@ paths:
default: ''
description: A short description.
type: string
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeRestore:
default: false
description: Whether to restore time upon viewing this dashboard
@ -6628,6 +6633,11 @@ paths:
required:
- pause
- value
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeFrom:
description: An ISO string indicating when to restore time from
type: string
@ -7093,6 +7103,11 @@ paths:
required:
- pause
- value
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeFrom:
description: An ISO string indicating when to restore time from
type: string
@ -7481,6 +7496,11 @@ paths:
required:
- pause
- value
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeFrom:
description: An ISO string indicating when to restore time from
type: string
@ -7926,6 +7946,11 @@ paths:
required:
- pause
- value
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeFrom:
description: An ISO string indicating when to restore time from
type: string
@ -8310,6 +8335,11 @@ paths:
required:
- pause
- value
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeFrom:
description: An ISO string indicating when to restore time from
type: string

View file

@ -7703,6 +7703,11 @@ paths:
default: ''
description: A short description.
type: string
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeRestore:
default: false
description: Whether to restore time upon viewing this dashboard
@ -8163,6 +8168,11 @@ paths:
required:
- pause
- value
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeFrom:
description: An ISO string indicating when to restore time from
type: string
@ -8628,6 +8638,11 @@ paths:
required:
- pause
- value
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeFrom:
description: An ISO string indicating when to restore time from
type: string
@ -9016,6 +9031,11 @@ paths:
required:
- pause
- value
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeFrom:
description: An ISO string indicating when to restore time from
type: string
@ -9461,6 +9481,11 @@ paths:
required:
- pause
- value
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeFrom:
description: An ISO string indicating when to restore time from
type: string
@ -9845,6 +9870,11 @@ paths:
required:
- pause
- value
tags:
items:
description: An array of tags applied to this dashboard
type: string
type: array
timeFrom:
description: An ISO string indicating when to restore time from
type: string

View file

@ -138,6 +138,8 @@ export const getSerializedState = ({
{ embeddablePersistableStateService: embeddableService }
);
// TODO Provide tags as an array of tag names in the attribute. In that case, tag references
// will be extracted by the server.
const savedObjectsTaggingApi = savedObjectsTaggingService?.getTaggingApi();
const references = savedObjectsTaggingApi?.ui.updateTagsReferences
? savedObjectsTaggingApi?.ui.updateTagsReferences(dashboardReferences, tags)

View file

@ -14,10 +14,13 @@ import type { Logger } from '@kbn/logging';
import { CreateResult, DeleteResult, SearchQuery } from '@kbn/content-management-plugin/common';
import { StorageContext } from '@kbn/content-management-plugin/server';
import type { SavedObjectTaggingStart } from '@kbn/saved-objects-tagging-plugin/server';
import type { SavedObjectReference } from '@kbn/core/server';
import type { ITagsClient, Tag } from '@kbn/saved-objects-tagging-oss-plugin/common';
import { DASHBOARD_SAVED_OBJECT_TYPE } from '../dashboard_saved_object';
import { cmServicesDefinition } from './cm_services';
import { DashboardSavedObjectAttributes } from '../dashboard_saved_object';
import { itemAttrsToSavedObjectAttrs, savedObjectToItem } from './latest';
import { itemAttrsToSavedObjectWithTags, savedObjectToItem } from './latest';
import type {
DashboardAttributes,
DashboardItem,
@ -28,8 +31,13 @@ import type {
DashboardUpdateOptions,
DashboardUpdateOut,
DashboardSearchOptions,
ReplaceTagReferencesByNameParams,
} from './latest';
const getRandomColor = (): string => {
return '#' + String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0');
};
const searchArgsToSOFindOptions = (
query: SearchQuery,
options: DashboardSearchOptions
@ -60,21 +68,117 @@ export class DashboardStorage {
constructor({
logger,
throwOnResultValidationError,
savedObjectsTagging,
}: {
logger: Logger;
throwOnResultValidationError: boolean;
savedObjectsTagging?: SavedObjectTaggingStart;
}) {
this.savedObjectsTagging = savedObjectsTagging;
this.logger = logger;
this.throwOnResultValidationError = throwOnResultValidationError ?? false;
}
private logger: Logger;
private savedObjectsTagging?: SavedObjectTaggingStart;
private throwOnResultValidationError: boolean;
private getTagNamesFromReferences(references: SavedObjectReference[], allTags: Tag[]) {
return Array.from(
new Set(
this.savedObjectsTagging
? this.savedObjectsTagging
.getTagsFromReferences(references, allTags)
.tags.map((tag) => tag.name)
: []
)
);
}
private getUniqueTagNames(
references: SavedObjectReference[],
newTagNames: string[],
allTags: Tag[]
) {
const referenceTagNames = this.getTagNamesFromReferences(references, allTags);
return new Set([...referenceTagNames, ...newTagNames]);
}
private async replaceTagReferencesByName(
references: SavedObjectReference[],
newTagNames: string[],
allTags: Tag[],
tagsClient?: ITagsClient
) {
const combinedTagNames = this.getUniqueTagNames(references, newTagNames, allTags);
const newTagIds = await this.convertTagNamesToIds(combinedTagNames, allTags, tagsClient);
return this.savedObjectsTagging?.replaceTagReferences(references, newTagIds) ?? references;
}
private async convertTagNamesToIds(
tagNames: Set<string>,
allTags: Tag[],
tagsClient?: ITagsClient
): Promise<string[]> {
const combinedTagNames = await this.createTagsIfNeeded(tagNames, allTags, tagsClient);
return Array.from(combinedTagNames).flatMap(
(tagName) => this.savedObjectsTagging?.convertTagNameToId(tagName, allTags) ?? []
);
}
private async createTagsIfNeeded(
tagNames: Set<string>,
allTags: Tag[],
tagsClient?: ITagsClient
) {
const tagsToCreate = Array.from(tagNames).filter(
(tagName) => !allTags.some((tag) => tag.name === tagName)
);
const tagCreationResults = await Promise.allSettled(
tagsToCreate.flatMap(
(tagName) =>
tagsClient?.create({
name: tagName,
description: '',
color: getRandomColor(),
}) ?? []
)
);
for (const result of tagCreationResults) {
if (result.status === 'rejected') {
this.logger.error(`Error creating tag: ${result.reason}`);
} else {
this.logger.info(`Tag created: ${result.value.name}`);
}
}
const createdTags = tagCreationResults
.filter((result): result is PromiseFulfilledResult<Tag> => result.status === 'fulfilled')
.map((result) => result.value);
// Remove tags that were not created
const invalidTagNames = tagsToCreate.filter(
(tagName) => !createdTags.some((tag) => tag.name === tagName)
);
invalidTagNames.forEach((tagName) => tagNames.delete(tagName));
// Add newly created tags to allTags
allTags.push(...createdTags);
const combinedTagNames = new Set([
...tagNames,
...createdTags.map((createdTag) => createdTag.name),
]);
return combinedTagNames;
}
async get(ctx: StorageContext, id: string): Promise<DashboardGetOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
const tagsClient = this.savedObjectsTagging?.createTagClient({ client: soClient });
const allTags = (await tagsClient?.getAll()) ?? [];
// Save data in DB
const {
saved_object: savedObject,
@ -83,7 +187,10 @@ export class DashboardStorage {
outcome,
} = await soClient.resolve<DashboardSavedObjectAttributes>(DASHBOARD_SAVED_OBJECT_TYPE, id);
const { item, error: itemError } = savedObjectToItem(savedObject, false);
const { item, error: itemError } = savedObjectToItem(savedObject, false, {
getTagNamesFromReferences: (references: SavedObjectReference[]) =>
this.getTagNamesFromReferences(references, allTags),
});
if (itemError) {
throw Boom.badRequest(`Invalid response. ${itemError.message}`);
}
@ -128,6 +235,8 @@ export class DashboardStorage {
): Promise<DashboardCreateOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
const tagsClient = this.savedObjectsTagging?.createTagClient({ client: soClient });
const allTags = tagsClient ? await tagsClient?.getAll() : [];
// Validate input (data & options) & UP transform them to the latest version
const { value: dataToLatest, error: dataError } = transforms.create.in.data.up<
@ -146,8 +255,16 @@ export class DashboardStorage {
throw Boom.badRequest(`Invalid options. ${optionsError.message}`);
}
const { attributes: soAttributes, error: attributesError } =
itemAttrsToSavedObjectAttrs(dataToLatest);
const {
attributes: soAttributes,
references: soReferences,
error: attributesError,
} = await itemAttrsToSavedObjectWithTags({
attributes: dataToLatest,
replaceTagReferencesByName: ({ references, newTagNames }: ReplaceTagReferencesByNameParams) =>
this.replaceTagReferencesByName(references, newTagNames, allTags, tagsClient),
incomingReferences: options.references,
});
if (attributesError) {
throw Boom.badRequest(`Invalid data. ${attributesError.message}`);
}
@ -156,10 +273,13 @@ export class DashboardStorage {
const savedObject = await soClient.create<DashboardSavedObjectAttributes>(
DASHBOARD_SAVED_OBJECT_TYPE,
soAttributes,
optionsToLatest
{ ...optionsToLatest, references: soReferences }
);
const { item, error: itemError } = savedObjectToItem(savedObject, false);
const { item, error: itemError } = savedObjectToItem(savedObject, false, {
getTagNamesFromReferences: (references: SavedObjectReference[]) =>
this.getTagNamesFromReferences(references, allTags),
});
if (itemError) {
throw Boom.badRequest(`Invalid response. ${itemError.message}`);
}
@ -197,6 +317,8 @@ export class DashboardStorage {
): Promise<DashboardUpdateOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
const tagsClient = this.savedObjectsTagging?.createTagClient({ client: soClient });
const allTags = (await tagsClient?.getAll()) ?? [];
// Validate input (data & options) & UP transform them to the latest version
const { value: dataToLatest, error: dataError } = transforms.update.in.data.up<
@ -215,8 +337,16 @@ export class DashboardStorage {
throw Boom.badRequest(`Invalid options. ${optionsError.message}`);
}
const { attributes: soAttributes, error: attributesError } =
itemAttrsToSavedObjectAttrs(dataToLatest);
const {
attributes: soAttributes,
references: soReferences,
error: attributesError,
} = await itemAttrsToSavedObjectWithTags({
attributes: dataToLatest,
replaceTagReferencesByName: ({ references, newTagNames }: ReplaceTagReferencesByNameParams) =>
this.replaceTagReferencesByName(references, newTagNames, allTags, tagsClient),
incomingReferences: options.references,
});
if (attributesError) {
throw Boom.badRequest(`Invalid data. ${attributesError.message}`);
}
@ -226,10 +356,13 @@ export class DashboardStorage {
DASHBOARD_SAVED_OBJECT_TYPE,
id,
soAttributes,
optionsToLatest
{ ...optionsToLatest, references: soReferences }
);
const { item, error: itemError } = savedObjectToItem(partialSavedObject, true);
const { item, error: itemError } = savedObjectToItem(partialSavedObject, true, {
getTagNamesFromReferences: (references: SavedObjectReference[]) =>
this.getTagNamesFromReferences(references, allTags),
});
if (itemError) {
throw Boom.badRequest(`Invalid response. ${itemError.message}`);
}
@ -278,6 +411,8 @@ export class DashboardStorage {
): Promise<DashboardSearchOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
const tagsClient = this.savedObjectsTagging?.createTagClient({ client: soClient });
const allTags = (await tagsClient?.getAll()) ?? [];
// Validate and UP transform the options
const { value: optionsToLatest, error: optionsError } = transforms.search.in.options.up<
@ -291,16 +426,20 @@ export class DashboardStorage {
const soQuery = searchArgsToSOFindOptions(query, optionsToLatest);
// Execute the query in the DB
const soResponse = await soClient.find<DashboardSavedObjectAttributes>(soQuery);
const hits = soResponse.saved_objects
.map((so) => {
const { item } = savedObjectToItem(so, false, {
allowedAttributes: soQuery.fields,
allowedReferences: optionsToLatest?.includeReferences,
});
return item;
})
// Ignore any saved objects that failed to convert to items.
.filter((item) => item !== null);
const hits = await Promise.all(
soResponse.saved_objects
.map(async (so) => {
const { item } = savedObjectToItem(so, false, {
allowedAttributes: soQuery.fields,
allowedReferences: optionsToLatest?.includeReferences,
getTagNamesFromReferences: (references: SavedObjectReference[]) =>
this.getTagNamesFromReferences(references, allTags),
});
return item;
})
// Ignore any saved objects that failed to convert to items.
.filter((item) => item !== null)
);
const response = {
hits,
pagination: {

View file

@ -84,11 +84,4 @@ export const serviceDefinition: ServicesDefinition = {
},
},
},
mSearch: {
out: {
result: {
schema: dashboardSavedObjectSchema,
},
},
},
};

View file

@ -51,18 +51,11 @@ export const serviceDefinition: ServicesDefinition = {
...serviceDefinitionV1.update?.in,
data: {
schema: dashboardAttributesSchema,
up: (data: DashboardCrudTypes['UpdateIn']['data']) => attributesTov3(data),
up: (data: DashboardCrudTypes['UpdateIn']['data']) => attributesTov3(data, [], () => []),
},
},
},
search: {
in: serviceDefinitionV1.search?.in,
},
mSearch: {
out: {
result: {
schema: dashboardSavedObjectSchema,
},
},
},
};

View file

@ -333,6 +333,11 @@ export const searchResultsAttributesSchema = schema.object({
defaultValue: false,
meta: { description: 'Whether to restore time upon viewing this dashboard' },
}),
tags: schema.maybe(
schema.arrayOf(
schema.string({ meta: { description: 'An array of tags applied to this dashboard' } })
)
),
});
export const dashboardAttributesSchema = searchResultsAttributesSchema.extends({
@ -553,11 +558,4 @@ export const serviceDefinition: ServicesDefinition = {
},
},
},
mSearch: {
out: {
result: {
schema: dashboardItemSchema,
},
},
},
};

View file

@ -25,6 +25,7 @@ export type {
DashboardUpdateOut,
DashboardUpdateOptions,
DashboardOptions,
ReplaceTagReferencesByNameParams,
} from './types';
export {
serviceDefinition,
@ -37,6 +38,7 @@ export {
} from './cm_services';
export {
dashboardAttributesOut,
itemAttrsToSavedObjectAttrs,
itemAttrsToSavedObject,
itemAttrsToSavedObjectWithTags,
savedObjectToItem,
} from './transform_utils';

View file

@ -8,17 +8,6 @@
*/
import type { SavedObject } from '@kbn/core-saved-objects-api-server';
import type {
DashboardSavedObjectAttributes,
SavedDashboardPanel,
} from '../../dashboard_saved_object';
import type { DashboardAttributes, DashboardItem } from './types';
import {
dashboardAttributesOut,
getResultV3ToV2,
itemAttrsToSavedObjectAttrs,
savedObjectToItem,
} from './transform_utils';
import {
DEFAULT_AUTO_APPLY_SELECTIONS,
DEFAULT_CONTROL_CHAINING,
@ -30,6 +19,19 @@ import {
ControlGroupChainingSystem,
ControlWidth,
} from '@kbn/controls-plugin/common';
import type {
DashboardSavedObjectAttributes,
SavedDashboardPanel,
} from '../../dashboard_saved_object';
import type { DashboardAttributes, DashboardItem } from './types';
import {
dashboardAttributesOut,
getResultV3ToV2,
itemAttrsToSavedObject,
savedObjectToItem,
} from './transform_utils';
import { DEFAULT_DASHBOARD_OPTIONS } from '../../../common/content_management';
describe('dashboardAttributesOut', () => {
@ -205,8 +207,8 @@ describe('dashboardAttributesOut', () => {
});
});
describe('itemAttrsToSavedObjectAttrs', () => {
it('should transform item attributes to saved object attributes correctly', () => {
describe('itemAttrsToSavedObject', () => {
it('should transform item attributes to saved object correctly', () => {
const input: DashboardAttributes = {
controlGroupInput: {
chainingSystem: 'NONE',
@ -250,6 +252,7 @@ describe('itemAttrsToSavedObjectAttrs', () => {
version: '2',
},
],
tags: [],
timeRestore: true,
title: 'title',
refreshInterval: { pause: true, value: 1000 },
@ -257,7 +260,7 @@ describe('itemAttrsToSavedObjectAttrs', () => {
timeTo: 'now',
};
const output = itemAttrsToSavedObjectAttrs(input);
const output = itemAttrsToSavedObject({ attributes: input });
expect(output).toMatchInlineSnapshot(`
Object {
"attributes": Object {
@ -284,6 +287,7 @@ describe('itemAttrsToSavedObjectAttrs', () => {
"title": "title",
},
"error": null,
"references": Array [],
}
`);
});
@ -298,7 +302,7 @@ describe('itemAttrsToSavedObjectAttrs', () => {
kibanaSavedObjectMeta: {},
};
const output = itemAttrsToSavedObjectAttrs(input);
const output = itemAttrsToSavedObject({ attributes: input });
expect(output).toMatchInlineSnapshot(`
Object {
"attributes": Object {
@ -312,6 +316,7 @@ describe('itemAttrsToSavedObjectAttrs', () => {
"title": "title",
},
"error": null,
"references": Array [],
}
`);
});
@ -333,6 +338,13 @@ describe('savedObjectToItem', () => {
attributes,
};
};
const getTagNamesFromReferences = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
});
it('should convert saved object to item with all attributes', () => {
const input = getSavedObjectForAttributes({
title: 'title',
@ -396,6 +408,51 @@ describe('savedObjectToItem', () => {
});
});
it('should pass references to getTagNamesFromReferences', () => {
getTagNamesFromReferences.mockReturnValue(['tag1', 'tag2']);
const input = {
...getSavedObjectForAttributes({
title: 'dashboard with tags',
description: 'I have some tags!',
timeRestore: true,
kibanaSavedObjectMeta: {},
panelsJSON: JSON.stringify([]),
}),
references: [
{
type: 'tag',
id: 'tag1',
name: 'tag-ref-tag1',
},
{
type: 'tag',
id: 'tag2',
name: 'tag-ref-tag2',
},
{
type: 'index-pattern',
id: 'index-pattern1',
name: 'index-pattern-ref-index-pattern1',
},
],
};
const { item, error } = savedObjectToItem(input, false, { getTagNamesFromReferences });
expect(getTagNamesFromReferences).toHaveBeenCalledWith(input.references);
expect(error).toBeNull();
expect(item).toEqual({
...commonSavedObject,
references: [...input.references],
attributes: {
title: 'dashboard with tags',
description: 'I have some tags!',
panels: [],
timeRestore: true,
kibanaSavedObjectMeta: {},
tags: ['tag1', 'tag2'],
},
});
});
it('should handle missing optional attributes', () => {
const input = getSavedObjectForAttributes({
title: 'title',

View file

@ -14,7 +14,9 @@ import type {
DashboardAttributes,
DashboardGetOut,
DashboardItem,
ItemAttrsToSavedObjectAttrsReturn,
ItemAttrsToSavedObjectParams,
ItemAttrsToSavedObjectReturn,
ItemAttrsToSavedObjectWithTagsParams,
PartialDashboardItem,
SavedObjectToItemReturn,
} from './types';
@ -34,7 +36,9 @@ import {
} from './transforms';
export function dashboardAttributesOut(
attributes: DashboardSavedObjectAttributes | Partial<DashboardSavedObjectAttributes>
attributes: DashboardSavedObjectAttributes | Partial<DashboardSavedObjectAttributes>,
references?: SavedObjectReference[],
getTagNamesFromReferences?: (references: SavedObjectReference[]) => string[]
): DashboardAttributes | Partial<DashboardAttributes> {
const {
controlGroupInput,
@ -49,6 +53,13 @@ export function dashboardAttributesOut(
title,
version,
} = attributes;
// Inject any tag names from references into the attributes
let tags: string[] | undefined;
if (getTagNamesFromReferences && references && references.length) {
tags = getTagNamesFromReferences(references);
}
// try to maintain a consistent (alphabetical) order of keys
return {
...(controlGroupInput && { controlGroupInput: transformControlGroupOut(controlGroupInput) }),
@ -61,6 +72,7 @@ export function dashboardAttributesOut(
...(refreshInterval && {
refreshInterval: { pause: refreshInterval.pause, value: refreshInterval.value },
}),
...(tags && tags.length && { tags }),
...(timeFrom && { timeFrom }),
timeRestore: timeRestore ?? false,
...(timeTo && { timeTo }),
@ -112,11 +124,12 @@ export const getResultV3ToV2 = (result: DashboardGetOut): DashboardCrudTypesV2['
};
};
export const itemAttrsToSavedObjectAttrs = (
attributes: DashboardAttributes
): ItemAttrsToSavedObjectAttrsReturn => {
export const itemAttrsToSavedObject = ({
attributes,
incomingReferences = [],
}: ItemAttrsToSavedObjectParams): ItemAttrsToSavedObjectReturn => {
try {
const { controlGroupInput, kibanaSavedObjectMeta, options, panels, ...rest } = attributes;
const { controlGroupInput, kibanaSavedObjectMeta, options, panels, tags, ...rest } = attributes;
const soAttributes = {
...rest,
...(controlGroupInput && {
@ -132,17 +145,34 @@ export const itemAttrsToSavedObjectAttrs = (
kibanaSavedObjectMeta: transformSearchSourceIn(kibanaSavedObjectMeta),
}),
};
return { attributes: soAttributes, error: null };
return { attributes: soAttributes, references: incomingReferences, error: null };
} catch (e) {
return { attributes: null, error: e };
return { attributes: null, references: null, error: e };
}
};
export const itemAttrsToSavedObjectWithTags = async ({
attributes,
replaceTagReferencesByName,
incomingReferences = [],
}: ItemAttrsToSavedObjectWithTagsParams): Promise<ItemAttrsToSavedObjectReturn> => {
const { tags, ...restAttributes } = attributes;
// Tags can be specified as an attribute or in the incomingReferences.
const soReferences =
replaceTagReferencesByName && tags && tags.length
? await replaceTagReferencesByName({ references: incomingReferences, newTagNames: tags })
: incomingReferences;
return itemAttrsToSavedObject({
attributes: restAttributes,
incomingReferences: soReferences,
});
};
type PartialSavedObject<T> = Omit<SavedObject<Partial<T>>, 'references'> & {
references: SavedObjectReference[] | undefined;
};
export interface SavedObjectToItemOptions {
interface SavedObjectToItemOptions {
/**
* attributes to include in the output item
*/
@ -151,6 +181,7 @@ export interface SavedObjectToItemOptions {
* references to include in the output item
*/
allowedReferences?: string[];
getTagNamesFromReferences?: (references: SavedObjectReference[]) => string[];
}
export function savedObjectToItem(
@ -170,7 +201,7 @@ export function savedObjectToItem(
| SavedObject<DashboardSavedObjectAttributes>
| PartialSavedObject<DashboardSavedObjectAttributes>,
partial: boolean /* partial arg is used to enforce the correct savedObject type */,
{ allowedAttributes, allowedReferences }: SavedObjectToItemOptions = {}
{ allowedAttributes, allowedReferences, getTagNamesFromReferences }: SavedObjectToItemOptions = {}
): SavedObjectToItemReturn<DashboardItem | PartialDashboardItem> {
const {
id,
@ -189,8 +220,11 @@ export function savedObjectToItem(
try {
const attributesOut = allowedAttributes
? pick(dashboardAttributesOut(attributes), allowedAttributes)
: dashboardAttributesOut(attributes);
? pick(
dashboardAttributesOut(attributes, references, getTagNamesFromReferences),
allowedAttributes
)
: dashboardAttributesOut(attributes, references, getTagNamesFromReferences);
// if includeReferences is provided, only include references of those types
const referencesOut = allowedReferences

View file

@ -81,12 +81,30 @@ export type SavedObjectToItemReturn<T> =
error: Error;
};
export type ItemAttrsToSavedObjectAttrsReturn =
export interface ItemAttrsToSavedObjectParams {
attributes: DashboardAttributes;
incomingReferences?: SavedObjectReference[];
}
export type ItemAttrsToSavedObjectReturn =
| {
attributes: DashboardSavedObjectAttributes;
references: SavedObjectReference[];
error: null;
}
| {
attributes: null;
references: null;
error: Error;
};
export interface ItemAttrsToSavedObjectWithTagsParams extends ItemAttrsToSavedObjectParams {
replaceTagReferencesByName?: (
params: ReplaceTagReferencesByNameParams
) => Promise<SavedObjectReference[]>;
}
export interface ReplaceTagReferencesByNameParams {
references: SavedObjectReference[];
newTagNames: string[];
}

View file

@ -12,7 +12,7 @@ import { SavedObject, SavedObjectMigrationFn } from '@kbn/core/server';
import { extractReferences, injectReferences } from '../../../common';
import type { DashboardSavedObjectTypeMigrationsDeps } from './dashboard_saved_object_migrations';
import type { DashboardSavedObjectAttributes } from '../schema';
import { itemAttrsToSavedObjectAttrs, savedObjectToItem } from '../../content_management/latest';
import { itemAttrsToSavedObject, savedObjectToItem } from '../../content_management/latest';
/**
* In 7.8.0 we introduced dashboard drilldowns which are stored inside dashboard saved object as part of embeddable state
@ -60,7 +60,9 @@ export function createExtractPanelReferencesMigration(
{ embeddablePersistableStateService: deps.embeddable }
);
const { attributes, error: attributesError } = itemAttrsToSavedObjectAttrs(extractedAttributes);
const { attributes, error: attributesError } = itemAttrsToSavedObject({
attributes: extractedAttributes,
});
if (attributesError) throw attributesError;
return {

View file

@ -17,6 +17,7 @@ import { ContentManagementServerSetup } from '@kbn/content-management-plugin/ser
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '@kbn/core/server';
import { registerContentInsights } from '@kbn/content-management-content-insights-server';
import type { SavedObjectTaggingStart } from '@kbn/saved-objects-tagging-plugin/server';
import {
initializeDashboardTelemetryTask,
scheduleDashboardTelemetry,
@ -42,6 +43,7 @@ interface SetupDeps {
interface StartDeps {
taskManager: TaskManagerStartContract;
usageCollection?: UsageCollectionStart;
savedObjectsTagging?: SavedObjectTaggingStart;
}
export class DashboardPlugin
@ -65,17 +67,20 @@ export class DashboardPlugin
})
);
const { contentClient } = plugins.contentManagement.register({
id: CONTENT_ID,
storage: new DashboardStorage({
throwOnResultValidationError: this.initializerContext.env.mode.dev,
logger: this.logger.get('storage'),
}),
version: {
latest: LATEST_VERSION,
},
void core.getStartServices().then(([_, { savedObjectsTagging }]) => {
const { contentClient } = plugins.contentManagement.register({
id: CONTENT_ID,
storage: new DashboardStorage({
throwOnResultValidationError: this.initializerContext.env.mode.dev,
logger: this.logger.get('storage'),
savedObjectsTagging,
}),
version: {
latest: LATEST_VERSION,
},
});
this.contentClient = contentClient;
});
this.contentClient = contentClient;
plugins.contentManagement.favorites.registerFavoriteType('dashboard');

View file

@ -83,7 +83,8 @@
"@kbn/core-rendering-browser",
"@kbn/grid-layout",
"@kbn/ui-actions-browser",
"@kbn/esql-types"
"@kbn/esql-types",
"@kbn/saved-objects-tagging-plugin"
],
"exclude": ["target/**/*"]
}

View file

@ -16,6 +16,9 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
await kibanaServer.importExport.load(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
await kibanaServer.importExport.load(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/tags.json'
);
});
after(async () => {
@ -23,6 +26,9 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
await kibanaServer.importExport.unload(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
await kibanaServer.importExport.unload(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/tags.json'
);
});
loadTestFile(require.resolve('./main'));
loadTestFile(require.resolve('./validation'));

View file

@ -8,6 +8,7 @@
*/
import expect from '@kbn/expect';
import { type SavedObjectReference } from '@kbn/core/server';
import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server';
import { DEFAULT_IGNORE_PARENT_SETTINGS } from '@kbn/controls-plugin/common';
import { FtrProviderContext } from '../../../ftr_provider_context';
@ -171,6 +172,153 @@ export default function ({ getService }: FtrProviderContext) {
expect(response.body.item.attributes.panels).to.be.an('array');
});
describe('create a dashboard with tags', () => {
it('with tags specified as an array of names', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title,
tags: ['foo'],
},
references: [
{
name: 'bizz:panel_bizz',
type: 'visualization',
id: 'my-saved-object',
},
],
});
expect(response.status).to.be(200);
expect(response.body.item.attributes.tags).to.contain('foo');
expect(response.body.item.attributes.tags).to.have.length(1);
// adds tag reference to existing references
const referenceIds = response.body.item.references.map(
(ref: SavedObjectReference) => ref.id
);
expect(referenceIds).to.contain('tag-1');
expect(referenceIds).to.contain('my-saved-object');
expect(response.body.item.references).to.have.length(2);
});
it('creates tags if a saved object matching a tag name is not found', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title,
tags: ['foo', 'not-found-tag'],
},
});
expect(response.status).to.be(200);
expect(response.body.item.attributes.tags).to.contain('foo', 'not-found-tag');
expect(response.body.item.attributes.tags).to.have.length(2);
const referenceIds = response.body.item.references.map(
(ref: SavedObjectReference) => ref.id
);
expect(referenceIds).to.contain('tag-1');
expect(response.body.item.references).to.have.length(2);
});
it('with tags specified as references', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title,
},
references: [
{
type: 'tag',
id: 'tag-3',
name: 'tag-ref-tag-3',
},
],
});
expect(response.status).to.be(200);
expect(response.body.item.attributes.tags).to.contain('buzz');
expect(response.body.item.attributes.tags).to.have.length(1);
const referenceIds = response.body.item.references.map(
(ref: SavedObjectReference) => ref.id
);
expect(referenceIds).to.contain('tag-3');
expect(response.body.item.references).to.have.length(1);
});
it('with tags specified using both tags array and references', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title,
tags: ['foo'],
},
references: [
{
type: 'tag',
id: 'tag-2',
name: 'tag-ref-tag-2',
},
],
});
expect(response.status).to.be(200);
expect(response.body.item.attributes.tags).to.contain('foo');
expect(response.body.item.attributes.tags).to.contain('bar');
expect(response.body.item.attributes.tags).to.have.length(2);
const referenceIds = response.body.item.references.map(
(ref: SavedObjectReference) => ref.id
);
expect(referenceIds).to.contain('tag-1');
expect(referenceIds).to.contain('tag-2');
expect(response.body.item.references).to.have.length(2);
});
it('with the same tag specified as a reference and a tag name', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title,
tags: ['foo', 'buzz'],
},
references: [
{
type: 'tag',
id: 'tag-1',
name: 'tag-ref-tag-1',
},
],
});
expect(response.status).to.be(200);
expect(response.body.item.attributes.tags).to.contain('foo');
expect(response.body.item.attributes.tags).to.contain('buzz');
expect(response.body.item.attributes.tags).to.have.length(2);
const referenceIds = response.body.item.references.map(
(ref: SavedObjectReference) => ref.id
);
expect(referenceIds).to.contain('tag-1');
expect(referenceIds).to.contain('tag-3');
expect(response.body.item.references).to.have.length(2);
});
});
// TODO Maybe move this test to x-pack/test/api_integration/dashboards
it('can create a dashboard in a defined space', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;

View file

@ -16,6 +16,9 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
await kibanaServer.importExport.load(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
await kibanaServer.importExport.load(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/tags.json'
);
});
after(async () => {
@ -23,6 +26,9 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
await kibanaServer.importExport.unload(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
await kibanaServer.importExport.unload(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/tags.json'
);
});
loadTestFile(require.resolve('./main'));
});

View file

@ -8,6 +8,7 @@
*/
import expect from '@kbn/expect';
import { type SavedObjectReference } from '@kbn/core/server';
import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server';
import { FtrProviderContext } from '../../../ftr_provider_context';
@ -30,5 +31,31 @@ export default function ({ getService }: FtrProviderContext) {
expect(response.body.item.attributes.options).to.not.have.keys(['darkTheme']);
expect(response.body.item.attributes.refreshInterval).to.not.have.keys(['display']);
});
it('should return 404 with a non-existing dashboard', async () => {
const response = await supertest
.get(`${PUBLIC_API_PATH}/does-not-exist`)
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send();
expect(response.status).to.be(404);
});
it('should inject tag names into attributes', async () => {
const response = await supertest
.get(`${PUBLIC_API_PATH}/8d66658a-f5b7-4482-84dc-f41d317473b8`)
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send();
expect(response.status).to.be(200);
expect(response.body.item.attributes.tags).to.contain('bar');
expect(response.body.item.attributes.tags).to.contain('buzz');
expect(response.body.item.attributes.tags).to.have.length(2);
const referenceIds = response.body.item.references.map((ref: SavedObjectReference) => ref.id);
expect(referenceIds).to.contain('tag-2');
expect(referenceIds).to.contain('tag-3');
expect(response.body.item.references).to.have.length(2);
});
});
}

View file

@ -11,36 +11,18 @@ import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, loadTestFile }: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
const supertest = getService('supertest');
describe('dashboards - list', () => {
const createManyDashboards = async (count: number) => {
const fileChunks: string[] = [];
for (let i = 0; i < count; i++) {
const id = `test-dashboard-${i}`;
fileChunks.push(
JSON.stringify({
type: 'dashboard',
id,
attributes: {
title: `My dashboard (${i})`,
kibanaSavedObjectMeta: { searchSourceJSON: '{}' },
},
references: [],
})
);
}
await supertest
.post(`/api/saved_objects/_import`)
.attach('file', Buffer.from(fileChunks.join('\n'), 'utf8'), 'export.ndjson')
.expect(200);
};
before(async () => {
await createManyDashboards(100);
await kibanaServer.importExport.load(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/many-dashboards.json'
);
});
after(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.unload(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/many-dashboards.json'
);
});
loadTestFile(require.resolve('./main'));
});

View file

@ -22,7 +22,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(response.status).to.be(200);
expect(response.body.total).to.be(100);
expect(response.body.items[0].id).to.be('test-dashboard-0');
expect(response.body.items[0].id).to.be('test-dashboard-00');
expect(response.body.items.length).to.be(20);
});

View file

@ -16,12 +16,18 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
await kibanaServer.importExport.load(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
await kibanaServer.importExport.load(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/tags.json'
);
});
after(async () => {
await kibanaServer.importExport.unload(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
await kibanaServer.importExport.unload(
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/tags.json'
);
});
loadTestFile(require.resolve('./main'));
loadTestFile(require.resolve('./validation'));

View file

@ -8,9 +8,36 @@
*/
import expect from '@kbn/expect';
import { type SavedObjectReference } from '@kbn/core/server';
import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server';
import { FtrProviderContext } from '../../../ftr_provider_context';
const updatedDashboard = {
attributes: {
title: 'Refresh Requests (Updated)',
options: { useMargins: false },
panels: [
{
type: 'visualization',
gridData: { x: 0, y: 0, w: 48, h: 60, i: '1' },
panelIndex: '1',
panelRefName: 'panel_1',
version: '7.3.0',
},
],
timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700',
timeRestore: true,
timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700',
},
references: [
{
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
name: '1:panel_1',
type: 'visualization',
},
],
};
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('main', () => {
@ -19,31 +46,7 @@ export default function ({ getService }: FtrProviderContext) {
.put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title: 'Refresh Requests (Updated)',
options: { useMargins: false },
panels: [
{
type: 'visualization',
gridData: { x: 0, y: 0, w: 48, h: 60, i: '1' },
panelIndex: '1',
panelRefName: 'panel_1',
version: '7.3.0',
},
],
timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700',
timeRestore: true,
timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700',
},
references: [
{
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
name: '1:panel_1',
type: 'visualization',
},
],
});
.send(updatedDashboard);
expect(response.status).to.be(201);
@ -70,5 +73,107 @@ export default function ({ getService }: FtrProviderContext) {
message: 'A dashboard with saved object ID not-an-id was not found.',
});
});
describe('update a dashboard with tags', () => {
it('adds a tag to the dashboard', async () => {
const response = await supertest
.put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
...updatedDashboard,
attributes: {
...updatedDashboard.attributes,
tags: ['bar'],
},
});
expect(response.status).to.be(201);
expect(response.body.item.attributes.tags).to.contain('bar');
expect(response.body.item.attributes.tags).to.have.length(1);
const referenceIds = response.body.item.references.map(
(ref: SavedObjectReference) => ref.id
);
expect(referenceIds).to.contain('tag-2');
expect(referenceIds).to.contain('dd7caf20-9efd-11e7-acb3-3dab96693fab');
expect(response.body.item.references).to.have.length(2);
});
it('replaces the tags on the dashboard', async () => {
const response = await supertest
.put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
...updatedDashboard,
attributes: {
...updatedDashboard.attributes,
tags: ['foo'],
},
});
expect(response.status).to.be(201);
expect(response.body.item.attributes.tags).to.contain('foo');
expect(response.body.item.attributes.tags).to.have.length(1);
const referenceIds = response.body.item.references.map(
(ref: SavedObjectReference) => ref.id
);
expect(referenceIds).to.contain('tag-1');
expect(referenceIds).to.contain('dd7caf20-9efd-11e7-acb3-3dab96693fab');
expect(response.body.item.references).to.have.length(2);
});
it('empty tags array removes all tags', async () => {
const response = await supertest
.put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
...updatedDashboard,
attributes: {
...updatedDashboard.attributes,
tags: [],
},
});
expect(response.status).to.be(201);
expect(response.body.item.attributes).not.to.have.property('tags');
const referenceIds = response.body.item.references.map(
(ref: SavedObjectReference) => ref.id
);
expect(referenceIds).to.contain('dd7caf20-9efd-11e7-acb3-3dab96693fab');
expect(response.body.item.references).to.have.length(1);
});
it('creates tag if a saved object matching a tag name is not found', async () => {
const randomTagName = `tag-${Math.random() * 1000}`;
const response = await supertest
.put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
...updatedDashboard,
attributes: {
...updatedDashboard.attributes,
tags: ['foo', 'bar', 'buzz', randomTagName],
},
});
expect(response.status).to.be(201);
expect(response.body.item.attributes.tags).to.contain('foo');
expect(response.body.item.attributes.tags).to.contain('bar');
expect(response.body.item.attributes.tags).to.contain('buzz');
expect(response.body.item.attributes.tags).to.contain(randomTagName);
expect(response.body.item.attributes.tags).to.have.length(4);
const referenceIds = response.body.item.references.map(
(ref: SavedObjectReference) => ref.id
);
expect(referenceIds).to.contain('tag-1');
expect(referenceIds).to.contain('tag-2');
expect(referenceIds).to.contain('tag-3');
expect(referenceIds).to.contain('dd7caf20-9efd-11e7-acb3-3dab96693fab');
expect(response.body.item.references).to.have.length(5);
});
});
});
}

View file

@ -0,0 +1,91 @@
{
"attributes": {
"color": "#123456",
"description": "Another awesome tag",
"name": "bar"
},
"coreMigrationVersion": "8.8.0",
"created_at": "2025-03-19T18:17:49.747Z",
"id": "tag-2",
"managed": false,
"references": [],
"type": "tag",
"typeMigrationVersion": "8.0.0",
"updated_at": "2025-03-19T18:17:49.747Z",
"version": "WzYxMywxXQ=="
}
{
"attributes": {
"color": "#000000",
"description": "Last but not least",
"name": "buzz"
},
"coreMigrationVersion": "8.8.0",
"created_at": "2025-03-19T18:17:49.747Z",
"id": "tag-3",
"managed": false,
"references": [],
"type": "tag",
"typeMigrationVersion": "8.0.0",
"updated_at": "2025-03-19T18:17:49.747Z",
"version": "WzYxNCwxXQ=="
}
{
"attributes": {
"controlGroupInput": {
"chainingSystem": "HIERARCHICAL",
"controlStyle": "oneLine",
"ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}",
"panelsJSON": "{}",
"showApplySelections": false
},
"description": "I have some tags!",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"}}"
},
"optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}",
"panelsJSON": "[{\"type\":\"visualization\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"savedVis\":{\"id\":\"\",\"title\":\"\",\"description\":\"\",\"type\":\"markdown\",\"params\":{\"fontSize\":12,\"openLinksInNewTab\":false,\"markdown\":\"# Hello world\"},\"uiState\":{},\"data\":{\"aggs\":[],\"searchSource\":{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}}}},\"panelIndex\":\"b9e32168-d39e-4289-ab07-30cfda9e00d4\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"b9e32168-d39e-4289-ab07-30cfda9e00d4\"}}]",
"timeRestore": false,
"title": "tagged dashboard",
"version": 3
},
"coreMigrationVersion": "8.8.0",
"created_at": "2025-03-19T18:18:29.671Z",
"id": "8d66658a-f5b7-4482-84dc-f41d317473b8",
"managed": false,
"references": [
{
"id": "tag-2",
"name": "tag-ref-tag-2",
"type": "tag"
},
{
"id": "tag-3",
"name": "tag-ref-tag-3",
"type": "tag"
}
],
"type": "dashboard",
"typeMigrationVersion": "10.2.0",
"updated_at": "2025-03-19T18:18:58.406Z",
"version": "WzQ1MTQxLDFd"
}
{
"attributes": {
"color": "#FF00FF",
"description": "My first tag!",
"name": "foo"
},
"coreMigrationVersion": "8.8.0",
"created_at": "2025-03-19T18:17:49.747Z",
"id": "tag-1",
"managed": false,
"references": [],
"type": "tag",
"typeMigrationVersion": "8.0.0",
"updated_at": "2025-03-19T18:17:49.747Z",
"version": "WzYxMiwxXQ=="
}