[8.x] [Dashboard] Inject / extract tag references (#214788) (#217590)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Dashboard] Inject / extract tag references
(#214788)](https://github.com/elastic/kibana/pull/214788)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Nick
Peihl","email":"nick.peihl@elastic.co"},"sourceCommit":{"committedDate":"2025-03-31T14:52:24Z","message":"[Dashboard]
Inject / extract tag references (#214788)\n\nFixes #210619\n\n##
Summary\n\nProvides a tags array on the request and response bodies of
dashboards.\n\nThis allows consumers of the Dashboards HTTP API and
internal RPC API to\nadd an array of tag names to the attributes in the
body of create and\nupdate endpoints. The dashboard server will be
responsible for\nconverting the tag names into references in the saved
object.\n\nIf, during creation or update, a tag name does not have a
matching tag\nsaved object, a new tag saved object will be created. If
the user lacks\npermissions to manage tags, then an error will be logged
in the server\nand the tag will not be added to the dashboard.\n\nThe
server also injects the tag references as an array of tag names in\nthe
attributes of the response body of get and search endpoints of the\nHTTP
and RPC APIs.\n\nFor backwards compatibility in create and update
endpoints, tags can\nalternatively be specified in the `references`
array in the options\ninstead of (or in addition to) the
`attributes.tags` in the request\nbody. Similarly, for backwards
compatibility, tag references are\nreturned in the `references` of the
response body of get and search\nendpoints.\n\nClient-side tag handling
is out of scope for this PR. Dashboards listing\npage and dashboard
settings continue to use the tag references and do\nnot use the `tags`
attribute in the response.\n\nFor example:\n\nHere's how we currently
create a dashboard with tag references.\n```\n## Creating a dashboard
with tag references\nPOST kbn:/api/dashboards/dashboard\n{\n
\"attributes\": {\n \"title\": \"my tagged dashboard\"\n },\n
\"references\": [\n {\n \"type\": \"tag\",\n \"id\":
\"37aab5de-a34d-47cb-9aa5-9375d5db595f\",\n \"name\":
\"tag-ref-37aab5de-a34d-47cb-9aa5-9375d5db595f\"\n },\n {\n \"type\":
\"tag\",\n \"id\": \"5ed29bba-c14d-4302-9a8c-9602e40dbc2a\",\n \"name\":
\"tag-ref-5ed29bba-c14d-4302-9a8c-9602e40dbc2a\"\n },\n {\n \"type\":
\"tag\",\n \"id\": \"fc7890e8-c00f-44a1-88a2-250e4d27e61d\",\n \"name\":
\"tag-ref-fc7890e8-c00f-44a1-88a2-250e4d27e61d\"\n }\n ]\n}\n```\n\nWith
this PR, creating a dashboard with tags is much simpler.\n\n```\n##
Creating a dashboard with tag names\nPOST
kbn:/api/dashboards/dashboard\n{\n \"attributes\": {\n \"title\": \"my
tagged dashboard\",\n \"tags\": [\n \"boo\",\n \"foo\",\n \"bingo\",\n
\"bongo\"\n ]\n }\n}\n\n```\n\n### Checklist\n\nCheck the PR satisfies
following conditions. \n\nReviewers should verify this PR satisfies this
list as well.\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nDoes this PR introduce any risks? For example,
consider risks like hard\nto test bugs, performance regression,
potential of data loss.\n\nDescribe the risk, its severity, and
mitigation for each identified\nrisk. Invite stakeholders and evaluate
how to proceed before merging.\n\n- [ ] If there are more than one tag
saved objects with the same name,\nonly one of the tag references will
be added to the saved object when\ncreating a dashboard. Creating tags
with duplicate names are not\npermitted via the UI. But there is no such
restrictions when creating\ntags from imported saved objects. Having
multiple tags with the same\nname is an edge case that Kibana guards
against with reasonable\nrestrictions, so I think we should not be too
concerned about it.\n- [ ] ...\n\n---------\n\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"c4f7c649b12b8189f1b7a00d4857c1372acc8755","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:enhancement","Team:Presentation","backport:version","v9.1.0","v8.19.0"],"title":"[Dashboard]
Inject / extract tag
references","number":214788,"url":"https://github.com/elastic/kibana/pull/214788","mergeCommit":{"message":"[Dashboard]
Inject / extract tag references (#214788)\n\nFixes #210619\n\n##
Summary\n\nProvides a tags array on the request and response bodies of
dashboards.\n\nThis allows consumers of the Dashboards HTTP API and
internal RPC API to\nadd an array of tag names to the attributes in the
body of create and\nupdate endpoints. The dashboard server will be
responsible for\nconverting the tag names into references in the saved
object.\n\nIf, during creation or update, a tag name does not have a
matching tag\nsaved object, a new tag saved object will be created. If
the user lacks\npermissions to manage tags, then an error will be logged
in the server\nand the tag will not be added to the dashboard.\n\nThe
server also injects the tag references as an array of tag names in\nthe
attributes of the response body of get and search endpoints of the\nHTTP
and RPC APIs.\n\nFor backwards compatibility in create and update
endpoints, tags can\nalternatively be specified in the `references`
array in the options\ninstead of (or in addition to) the
`attributes.tags` in the request\nbody. Similarly, for backwards
compatibility, tag references are\nreturned in the `references` of the
response body of get and search\nendpoints.\n\nClient-side tag handling
is out of scope for this PR. Dashboards listing\npage and dashboard
settings continue to use the tag references and do\nnot use the `tags`
attribute in the response.\n\nFor example:\n\nHere's how we currently
create a dashboard with tag references.\n```\n## Creating a dashboard
with tag references\nPOST kbn:/api/dashboards/dashboard\n{\n
\"attributes\": {\n \"title\": \"my tagged dashboard\"\n },\n
\"references\": [\n {\n \"type\": \"tag\",\n \"id\":
\"37aab5de-a34d-47cb-9aa5-9375d5db595f\",\n \"name\":
\"tag-ref-37aab5de-a34d-47cb-9aa5-9375d5db595f\"\n },\n {\n \"type\":
\"tag\",\n \"id\": \"5ed29bba-c14d-4302-9a8c-9602e40dbc2a\",\n \"name\":
\"tag-ref-5ed29bba-c14d-4302-9a8c-9602e40dbc2a\"\n },\n {\n \"type\":
\"tag\",\n \"id\": \"fc7890e8-c00f-44a1-88a2-250e4d27e61d\",\n \"name\":
\"tag-ref-fc7890e8-c00f-44a1-88a2-250e4d27e61d\"\n }\n ]\n}\n```\n\nWith
this PR, creating a dashboard with tags is much simpler.\n\n```\n##
Creating a dashboard with tag names\nPOST
kbn:/api/dashboards/dashboard\n{\n \"attributes\": {\n \"title\": \"my
tagged dashboard\",\n \"tags\": [\n \"boo\",\n \"foo\",\n \"bingo\",\n
\"bongo\"\n ]\n }\n}\n\n```\n\n### Checklist\n\nCheck the PR satisfies
following conditions. \n\nReviewers should verify this PR satisfies this
list as well.\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nDoes this PR introduce any risks? For example,
consider risks like hard\nto test bugs, performance regression,
potential of data loss.\n\nDescribe the risk, its severity, and
mitigation for each identified\nrisk. Invite stakeholders and evaluate
how to proceed before merging.\n\n- [ ] If there are more than one tag
saved objects with the same name,\nonly one of the tag references will
be added to the saved object when\ncreating a dashboard. Creating tags
with duplicate names are not\npermitted via the UI. But there is no such
restrictions when creating\ntags from imported saved objects. Having
multiple tags with the same\nname is an edge case that Kibana guards
against with reasonable\nrestrictions, so I think we should not be too
concerned about it.\n- [ ] ...\n\n---------\n\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"c4f7c649b12b8189f1b7a00d4857c1372acc8755"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/214788","number":214788,"mergeCommit":{"message":"[Dashboard]
Inject / extract tag references (#214788)\n\nFixes #210619\n\n##
Summary\n\nProvides a tags array on the request and response bodies of
dashboards.\n\nThis allows consumers of the Dashboards HTTP API and
internal RPC API to\nadd an array of tag names to the attributes in the
body of create and\nupdate endpoints. The dashboard server will be
responsible for\nconverting the tag names into references in the saved
object.\n\nIf, during creation or update, a tag name does not have a
matching tag\nsaved object, a new tag saved object will be created. If
the user lacks\npermissions to manage tags, then an error will be logged
in the server\nand the tag will not be added to the dashboard.\n\nThe
server also injects the tag references as an array of tag names in\nthe
attributes of the response body of get and search endpoints of the\nHTTP
and RPC APIs.\n\nFor backwards compatibility in create and update
endpoints, tags can\nalternatively be specified in the `references`
array in the options\ninstead of (or in addition to) the
`attributes.tags` in the request\nbody. Similarly, for backwards
compatibility, tag references are\nreturned in the `references` of the
response body of get and search\nendpoints.\n\nClient-side tag handling
is out of scope for this PR. Dashboards listing\npage and dashboard
settings continue to use the tag references and do\nnot use the `tags`
attribute in the response.\n\nFor example:\n\nHere's how we currently
create a dashboard with tag references.\n```\n## Creating a dashboard
with tag references\nPOST kbn:/api/dashboards/dashboard\n{\n
\"attributes\": {\n \"title\": \"my tagged dashboard\"\n },\n
\"references\": [\n {\n \"type\": \"tag\",\n \"id\":
\"37aab5de-a34d-47cb-9aa5-9375d5db595f\",\n \"name\":
\"tag-ref-37aab5de-a34d-47cb-9aa5-9375d5db595f\"\n },\n {\n \"type\":
\"tag\",\n \"id\": \"5ed29bba-c14d-4302-9a8c-9602e40dbc2a\",\n \"name\":
\"tag-ref-5ed29bba-c14d-4302-9a8c-9602e40dbc2a\"\n },\n {\n \"type\":
\"tag\",\n \"id\": \"fc7890e8-c00f-44a1-88a2-250e4d27e61d\",\n \"name\":
\"tag-ref-fc7890e8-c00f-44a1-88a2-250e4d27e61d\"\n }\n ]\n}\n```\n\nWith
this PR, creating a dashboard with tags is much simpler.\n\n```\n##
Creating a dashboard with tag names\nPOST
kbn:/api/dashboards/dashboard\n{\n \"attributes\": {\n \"title\": \"my
tagged dashboard\",\n \"tags\": [\n \"boo\",\n \"foo\",\n \"bingo\",\n
\"bongo\"\n ]\n }\n}\n\n```\n\n### Checklist\n\nCheck the PR satisfies
following conditions. \n\nReviewers should verify this PR satisfies this
list as well.\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nDoes this PR introduce any risks? For example,
consider risks like hard\nto test bugs, performance regression,
potential of data loss.\n\nDescribe the risk, its severity, and
mitigation for each identified\nrisk. Invite stakeholders and evaluate
how to proceed before merging.\n\n- [ ] If there are more than one tag
saved objects with the same name,\nonly one of the tag references will
be added to the saved object when\ncreating a dashboard. Creating tags
with duplicate names are not\npermitted via the UI. But there is no such
restrictions when creating\ntags from imported saved objects. Having
multiple tags with the same\nname is an edge case that Kibana guards
against with reasonable\nrestrictions, so I think we should not be too
concerned about it.\n- [ ] ...\n\n---------\n\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"c4f7c649b12b8189f1b7a00d4857c1372acc8755"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Nick Peihl 2025-04-09 14:17:04 -04:00 committed by GitHub
parent 56b0868f48
commit b078ff2f99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2893 additions and 135 deletions

View file

@ -6293,6 +6293,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",
@ -6939,6 +6946,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"
@ -7593,6 +7607,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"
@ -8131,6 +8152,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"
@ -8757,6 +8785,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"
@ -9289,6 +9324,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

@ -5830,6 +5830,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",
@ -6476,6 +6483,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"
@ -7130,6 +7144,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"
@ -7668,6 +7689,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"
@ -8294,6 +8322,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"
@ -8826,6 +8861,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

@ -5540,6 +5540,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
@ -6000,6 +6005,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
@ -6465,6 +6475,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
@ -6853,6 +6868,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
@ -7298,6 +7318,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
@ -7682,6 +7707,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

@ -8056,6 +8056,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
@ -8516,6 +8521,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
@ -8981,6 +8991,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
@ -9369,6 +9384,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
@ -9814,6 +9834,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
@ -10198,6 +10223,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=="
}