From 2cb977fa5012c5d451e3adffb518d2d4de837e62 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 4 May 2023 17:06:45 -0400 Subject: [PATCH 01/53] [Dashboard Navigation] Create and register navigation embeddable plugin (#156627) --- .github/CODEOWNERS | 1 + .i18nrc.json | 1 + docs/developer/plugin-list.asciidoc | 4 ++ package.json | 1 + packages/kbn-optimizer/limits.yml | 1 + src/plugins/navigation_embeddable/README.md | 3 ++ .../navigation_embeddable/kibana.jsonc | 17 ++++++++ .../navigation_embeddable/public/index.ts | 20 ++++++++++ .../public/navigation_embeddable/index.ts | 11 ++++++ .../navigation_embeddable.tsx | 32 +++++++++++++++ .../navigation_embeddable_factory.ts | 36 +++++++++++++++++ .../navigation_embeddable/public/plugin.ts | 39 +++++++++++++++++++ .../navigation_embeddable/tsconfig.json | 15 +++++++ tsconfig.base.json | 2 + yarn.lock | 4 ++ 15 files changed, 187 insertions(+) create mode 100644 src/plugins/navigation_embeddable/README.md create mode 100644 src/plugins/navigation_embeddable/kibana.jsonc create mode 100644 src/plugins/navigation_embeddable/public/index.ts create mode 100644 src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts create mode 100644 src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx create mode 100644 src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts create mode 100644 src/plugins/navigation_embeddable/public/plugin.ts create mode 100644 src/plugins/navigation_embeddable/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4e23e6d1f00cc..2b4250d27d6a8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -499,6 +499,7 @@ x-pack/packages/ml/url_state @elastic/ml-ui packages/kbn-monaco @elastic/appex-sharedux x-pack/plugins/monitoring_collection @elastic/infra-monitoring-ui x-pack/plugins/monitoring @elastic/infra-monitoring-ui +src/plugins/navigation_embeddable @elastic/kibana-presentation src/plugins/navigation @elastic/appex-sharedux src/plugins/newsfeed @elastic/kibana-core test/common/plugins/newsfeed @elastic/kibana-core diff --git a/.i18nrc.json b/.i18nrc.json index 3d457a16a3fbc..7c200c947b28f 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -74,6 +74,7 @@ "management": ["src/legacy/core_plugins/management", "src/plugins/management"], "monaco": "packages/kbn-monaco/src", "navigation": "src/plugins/navigation", + "navigationEmbeddable": "src/plugins/navigation_embeddable", "newsfeed": "src/plugins/newsfeed", "presentationUtil": "src/plugins/presentation_util", "randomSampling": "x-pack/packages/kbn-random-sampling", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 31d7fe690cfe3..3294092957744 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -249,6 +249,10 @@ management section itself. It also provides a stateful version of it on the start contract. +|{kib-repo}blob/{branch}/src/plugins/navigation_embeddable/README.md[navigationEmbeddable] +|This plugin adds the Navigation Embeddable which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard. + + |{kib-repo}blob/{branch}/src/plugins/newsfeed/README.md[newsfeed] |The newsfeed plugin adds a NewsfeedNavButton to the top navigation bar and renders the content in the flyout. Content is fetched from the remote (https://feeds.elastic.co) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. diff --git a/package.json b/package.json index a21bd7f6aa01c..a6e05507e119e 100644 --- a/package.json +++ b/package.json @@ -516,6 +516,7 @@ "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/monitoring-collection-plugin": "link:x-pack/plugins/monitoring_collection", "@kbn/monitoring-plugin": "link:x-pack/plugins/monitoring", + "@kbn/navigation-embeddable-plugin": "link:src/plugins/navigation_embeddable", "@kbn/navigation-plugin": "link:src/plugins/navigation", "@kbn/newsfeed-plugin": "link:src/plugins/newsfeed", "@kbn/newsfeed-test-plugin": "link:test/common/plugins/newsfeed", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index fe708fe8c95c5..5b53136ab79f0 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -94,6 +94,7 @@ pageLoadAssetSize: ml: 82187 monitoring: 80000 navigation: 37269 + navigationEmbeddable: 17892 newsfeed: 42228 observability: 115443 observabilityOnboarding: 19573 diff --git a/src/plugins/navigation_embeddable/README.md b/src/plugins/navigation_embeddable/README.md new file mode 100644 index 0000000000000..598a29be2e037 --- /dev/null +++ b/src/plugins/navigation_embeddable/README.md @@ -0,0 +1,3 @@ +# Navigation Embeddable + +This plugin adds the Navigation Embeddable which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard. diff --git a/src/plugins/navigation_embeddable/kibana.jsonc b/src/plugins/navigation_embeddable/kibana.jsonc new file mode 100644 index 0000000000000..76d185c560084 --- /dev/null +++ b/src/plugins/navigation_embeddable/kibana.jsonc @@ -0,0 +1,17 @@ +{ + "type": "plugin", + "id": "@kbn/navigation-embeddable-plugin", + "owner": "@elastic/kibana-presentation", + "description": "An embeddable for quickly navigating between dashboards.", + "plugin": { + "id": "navigationEmbeddable", + "server": false, + "browser": true, + "requiredPlugins": [ + "embeddable" + ], + "optionalPlugins": [], + "requiredBundles": [ + ] + } +} diff --git a/src/plugins/navigation_embeddable/public/index.ts b/src/plugins/navigation_embeddable/public/index.ts new file mode 100644 index 0000000000000..5e8be17c8958b --- /dev/null +++ b/src/plugins/navigation_embeddable/public/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { NavigationEmbeddableFactory } from './navigation_embeddable'; +export { + NAVIGATION_EMBEDDABLE_TYPE, + NavigationEmbeddableFactoryDefinition, + NavigationEmbeddable, +} from './navigation_embeddable'; + +import { NavigationEmbeddablePlugin } from './plugin'; + +export function plugin() { + return new NavigationEmbeddablePlugin(); +} diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts b/src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts new file mode 100644 index 0000000000000..1676f521ceb84 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { NAVIGATION_EMBEDDABLE_TYPE, NavigationEmbeddable } from './navigation_embeddable'; +export type { NavigationEmbeddableFactory } from './navigation_embeddable_factory'; +export { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable_factory'; diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx b/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx new file mode 100644 index 0000000000000..2c66f4b655fac --- /dev/null +++ b/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiTitle } from '@elastic/eui'; +import { Embeddable } from '@kbn/embeddable-plugin/public'; +import type { EmbeddableInput, IContainer } from '@kbn/embeddable-plugin/public'; + +export const NAVIGATION_EMBEDDABLE_TYPE = 'navigation'; + +export class NavigationEmbeddable extends Embeddable { + public readonly type = NAVIGATION_EMBEDDABLE_TYPE; + + constructor(initialInput: EmbeddableInput, parent?: IContainer) { + super(initialInput, {}, parent); + } + + public render(el: HTMLElement) { + return ( + +

Call me Magellan, cuz I'm a navigator!

+
+ ); + } + + public reload() {} +} diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts new file mode 100644 index 0000000000000..54f8ecbb9f892 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import type { EmbeddableInput, IContainer } from '@kbn/embeddable-plugin/public'; +import { EmbeddableFactory, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; +import { NavigationEmbeddable, NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; + +export type NavigationEmbeddableFactory = EmbeddableFactory; + +export class NavigationEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { + public readonly type = NAVIGATION_EMBEDDABLE_TYPE; + + public async isEditable() { + return true; + } + + public async create(initialInput: EmbeddableInput, parent?: IContainer) { + return new NavigationEmbeddable(initialInput, parent); + } + + public getDisplayName() { + return i18n.translate('navigationEmbeddable.navigationEmbeddableFactory.displayName', { + defaultMessage: 'Navigation', + }); + } + + public getIconType() { + return 'link'; + } +} diff --git a/src/plugins/navigation_embeddable/public/plugin.ts b/src/plugins/navigation_embeddable/public/plugin.ts new file mode 100644 index 0000000000000..23dff1f5d1eb8 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/plugin.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import { NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; +import { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable'; + +export interface SetupDependencies { + embeddable: EmbeddableSetup; +} + +export interface StartDependencies { + embeddable: EmbeddableStart; +} + +export class NavigationEmbeddablePlugin + implements Plugin +{ + constructor() {} + + public setup(core: CoreSetup, plugins: SetupDependencies) { + plugins.embeddable.registerEmbeddableFactory( + NAVIGATION_EMBEDDABLE_TYPE, + new NavigationEmbeddableFactoryDefinition() + ); + } + + public start(core: CoreStart, plugins: StartDependencies) { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/navigation_embeddable/tsconfig.json b/src/plugins/navigation_embeddable/tsconfig.json new file mode 100644 index 0000000000000..05ce2326f9768 --- /dev/null +++ b/src/plugins/navigation_embeddable/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + }, + "include": ["public/**/*", "common/**/*", "server/**/*"], + "kbn_references": [ + "@kbn/core", + "@kbn/embeddable-plugin", + "@kbn/i18n", + ], + "exclude": [ + "target/**/*", + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index eb7ad0b4b8f55..20bbcb50377d5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -992,6 +992,8 @@ "@kbn/monitoring-collection-plugin/*": ["x-pack/plugins/monitoring_collection/*"], "@kbn/monitoring-plugin": ["x-pack/plugins/monitoring"], "@kbn/monitoring-plugin/*": ["x-pack/plugins/monitoring/*"], + "@kbn/navigation-embeddable-plugin": ["src/plugins/navigation_embeddable"], + "@kbn/navigation-embeddable-plugin/*": ["src/plugins/navigation_embeddable/*"], "@kbn/navigation-plugin": ["src/plugins/navigation"], "@kbn/navigation-plugin/*": ["src/plugins/navigation/*"], "@kbn/newsfeed-plugin": ["src/plugins/newsfeed"], diff --git a/yarn.lock b/yarn.lock index 3bd506adc6293..c266b33f3d400 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4824,6 +4824,10 @@ version "0.0.0" uid "" +"@kbn/navigation-embeddable-plugin@link:src/plugins/navigation_embeddable": + version "0.0.0" + uid "" + "@kbn/navigation-plugin@link:src/plugins/navigation": version "0.0.0" uid "" From dcc37c65a87d5ff255e8cd3850deda296f73ba9f Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 29 Jun 2023 10:05:10 -0400 Subject: [PATCH 02/53] Initialize content management --- .../navigation_embeddable/common/constants.ts | 17 +++ .../common/content_management/cm_services.ts | 21 ++++ .../common/content_management/index.ts | 19 ++++ .../common/content_management/latest.ts | 9 ++ .../content_management/v1/cm_services.ts | 100 ++++++++++++++++++ .../common/content_management/v1/index.ts | 11 ++ .../common/content_management/v1/types.ts | 32 ++++++ .../navigation_embeddable/common/index.ts | 9 ++ .../navigation_embeddable/common/types.ts | 9 ++ .../navigation_embeddable/kibana.jsonc | 8 +- .../public/content_management/index.ts | 9 ++ ...embeddable_content_management_client.ts.ts | 80 ++++++++++++++ .../index.ts | 0 .../navigation_embeddable.tsx | 0 .../navigation_embeddable_factory.tsx} | 4 +- .../navigation_embeddable/public/index.ts | 4 +- .../public/navigation_embeddable_services.ts | 44 ++++++++ .../navigation_embeddable/public/plugin.ts | 29 ++++- .../server/content_management/index.ts | 9 ++ .../navigation_embeddable_storage.ts | 23 ++++ .../navigation_embeddable/server/index.ts | 11 ++ .../navigation_embeddable/server/plugin.ts | 52 +++++++++ 22 files changed, 492 insertions(+), 8 deletions(-) create mode 100644 src/plugins/navigation_embeddable/common/constants.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/cm_services.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/index.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/latest.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/index.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/types.ts create mode 100644 src/plugins/navigation_embeddable/common/index.ts create mode 100644 src/plugins/navigation_embeddable/common/types.ts create mode 100644 src/plugins/navigation_embeddable/public/content_management/index.ts create mode 100644 src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts rename src/plugins/navigation_embeddable/public/{navigation_embeddable => embeddable}/index.ts (100%) rename src/plugins/navigation_embeddable/public/{navigation_embeddable => embeddable}/navigation_embeddable.tsx (100%) rename src/plugins/navigation_embeddable/public/{navigation_embeddable/navigation_embeddable_factory.ts => embeddable/navigation_embeddable_factory.tsx} (92%) create mode 100644 src/plugins/navigation_embeddable/public/navigation_embeddable_services.ts create mode 100644 src/plugins/navigation_embeddable/server/content_management/index.ts create mode 100644 src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts create mode 100644 src/plugins/navigation_embeddable/server/index.ts create mode 100644 src/plugins/navigation_embeddable/server/plugin.ts diff --git a/src/plugins/navigation_embeddable/common/constants.ts b/src/plugins/navigation_embeddable/common/constants.ts new file mode 100644 index 0000000000000..e71ee4836638b --- /dev/null +++ b/src/plugins/navigation_embeddable/common/constants.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const LATEST_VERSION = 1; + +export const CONTENT_ID = 'navigationEmbeddable'; + +export const APP_NAME = i18n.translate('xpack.maps.visTypeAlias.title', { + defaultMessage: 'Links', +}); diff --git a/src/plugins/navigation_embeddable/common/content_management/cm_services.ts b/src/plugins/navigation_embeddable/common/content_management/cm_services.ts new file mode 100644 index 0000000000000..af32d57e0437f --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/cm_services.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ContentManagementServicesDefinition as ServicesDefinition, + Version, +} from '@kbn/object-versioning'; + +// We export the versionned service definition from this file and not the barrel to avoid adding +// the schemas in the "public" js bundle + +import { serviceDefinition as v1 } from './v1/cm_services'; + +export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = { + 1: v1, +}; diff --git a/src/plugins/navigation_embeddable/common/content_management/index.ts b/src/plugins/navigation_embeddable/common/content_management/index.ts new file mode 100644 index 0000000000000..3c5ca47d812c4 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { LATEST_VERSION, CONTENT_ID } from '../constants'; + +export type { NavigationEmbeddableContentType } from '../types'; + +export type { + NavigationEmbeddableCrudTypes, + NavigationEmbeddableAttributes, + NavigationEmbeddableItem, +} from './latest'; + +export * as NavigationEmbeddableV1 from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/latest.ts b/src/plugins/navigation_embeddable/common/content_management/latest.ts new file mode 100644 index 0000000000000..e9c79f0f50f93 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/latest.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts new file mode 100644 index 0000000000000..66d1df1454892 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning'; +import { + savedObjectSchema, + objectTypeToGetResultSchema, + createOptionsSchemas, + updateOptionsSchema, + createResultSchema, +} from '@kbn/content-management-utils'; + +const navigationEmbeddableAttributesSchema = schema.object( + { + title: schema.string(), + description: schema.maybe(schema.string()), + linkListJSON: schema.maybe(schema.string()), + }, + { unknowns: 'forbid' } +); + +const navigationEmbeddableSavedObjectSchema = savedObjectSchema( + navigationEmbeddableAttributesSchema +); + +const searchOptionsSchema = schema.maybe( + schema.object( + { + onlyTitle: schema.maybe(schema.boolean()), + }, + { unknowns: 'forbid' } + ) +); + +const navigationEmbeddableCreateOptionsSchema = schema.object({ + id: createOptionsSchemas.id, + references: schema.maybe(createOptionsSchemas.references), + overwrite: createOptionsSchemas.overwrite, +}); + +const navigationEmbeddableUpdateOptionsSchema = schema.object({ + references: updateOptionsSchema.references, +}); + +// Content management service definition. +// We need it for BWC support between different versions of the content +export const serviceDefinition: ServicesDefinition = { + get: { + out: { + result: { + schema: objectTypeToGetResultSchema(navigationEmbeddableSavedObjectSchema), + }, + }, + }, + create: { + in: { + options: { + schema: navigationEmbeddableCreateOptionsSchema, + }, + data: { + schema: navigationEmbeddableSavedObjectSchema, + }, + }, + out: { + result: { + schema: createResultSchema(navigationEmbeddableSavedObjectSchema), + }, + }, + }, + update: { + in: { + options: { + schema: navigationEmbeddableUpdateOptionsSchema, // same schema as "create" + }, + data: { + schema: navigationEmbeddableSavedObjectSchema, + }, + }, + }, + search: { + in: { + options: { + schema: searchOptionsSchema, + }, + }, + }, + mSearch: { + out: { + result: { + schema: navigationEmbeddableSavedObjectSchema, + }, + }, + }, +}; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts new file mode 100644 index 0000000000000..8ef0422178d54 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { NavigationEmbeddableCrudTypes } from './types'; +export type { NavigationEmbeddableCrudTypes, NavigationEmbeddableAttributes } from './types'; +export type NavigationEmbeddableItem = NavigationEmbeddableCrudTypes['Item']; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts new file mode 100644 index 0000000000000..00a989292f933 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ContentManagementCrudTypes, + SavedObjectCreateOptions, + SavedObjectUpdateOptions, +} from '@kbn/content-management-utils'; +import { NavigationEmbeddableContentType } from '../../types'; + +export type NavigationEmbeddableCrudTypes = ContentManagementCrudTypes< + NavigationEmbeddableContentType, + NavigationEmbeddableAttributes, + Pick, + Pick, + { + /** Flag to indicate to only search the text on the "title" field */ + onlyTitle?: boolean; + } +>; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type NavigationEmbeddableAttributes = { + title: string; + description?: string; + linkListJSON?: string; +}; diff --git a/src/plugins/navigation_embeddable/common/index.ts b/src/plugins/navigation_embeddable/common/index.ts new file mode 100644 index 0000000000000..1c5c974297430 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { CONTENT_ID, LATEST_VERSION } from './constants'; diff --git a/src/plugins/navigation_embeddable/common/types.ts b/src/plugins/navigation_embeddable/common/types.ts new file mode 100644 index 0000000000000..2038a5c0f04c1 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type NavigationEmbeddableContentType = 'navigationEmbeddable'; diff --git a/src/plugins/navigation_embeddable/kibana.jsonc b/src/plugins/navigation_embeddable/kibana.jsonc index 76d185c560084..d0b8f4d43c9eb 100644 --- a/src/plugins/navigation_embeddable/kibana.jsonc +++ b/src/plugins/navigation_embeddable/kibana.jsonc @@ -5,10 +5,14 @@ "description": "An embeddable for quickly navigating between dashboards.", "plugin": { "id": "navigationEmbeddable", - "server": false, + "server": true, "browser": true, "requiredPlugins": [ - "embeddable" + "contentManagement", + "dashboard", + "embeddable", + "kibanaReact", + "presentationUtil" ], "optionalPlugins": [], "requiredBundles": [ diff --git a/src/plugins/navigation_embeddable/public/content_management/index.ts b/src/plugins/navigation_embeddable/public/content_management/index.ts new file mode 100644 index 0000000000000..abdf2fd0159eb --- /dev/null +++ b/src/plugins/navigation_embeddable/public/content_management/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { navigationEmbeddableClient } from './navigation_embeddable_content_management_client.ts'; diff --git a/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts b/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts new file mode 100644 index 0000000000000..800c7ff5eb59f --- /dev/null +++ b/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SearchQuery } from '@kbn/content-management-plugin/common'; +import type { NavigationEmbeddableCrudTypes } from '../../common/content_management'; +import { CONTENT_ID as contentTypeId } from '../../common'; +import { contentManagement } from '../navigation_embeddable_services'; + +const get = async (id: string) => { + return contentManagement.client.get({ contentTypeId, id }); +}; + +const create = async ({ + data, + options, +}: Omit) => { + const res = await contentManagement.client.create< + NavigationEmbeddableCrudTypes['CreateIn'], + NavigationEmbeddableCrudTypes['CreateOut'] + >({ + contentTypeId, + data, + options, + }); + return res; +}; + +const update = async ({ + id, + data, + options, +}: Omit) => { + const res = await contentManagement.client.update< + NavigationEmbeddableCrudTypes['UpdateIn'], + NavigationEmbeddableCrudTypes['UpdateOut'] + >({ + contentTypeId, + id, + data, + options, + }); + return res; +}; + +const deleteNavigationEmbeddable = async (id: string) => { + await contentManagement.client.delete< + NavigationEmbeddableCrudTypes['DeleteIn'], + NavigationEmbeddableCrudTypes['DeleteOut'] + >({ + contentTypeId, + id, + }); +}; + +const search = async ( + query: SearchQuery = {}, + options?: NavigationEmbeddableCrudTypes['SearchOptions'] +) => { + return contentManagement.client.search< + NavigationEmbeddableCrudTypes['SearchIn'], + NavigationEmbeddableCrudTypes['SearchOut'] + >({ + contentTypeId, + query, + options, + }); +}; + +export const navigationEmbeddableClient = { + get, + create, + update, + delete: deleteNavigationEmbeddable, + search, +}; diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts b/src/plugins/navigation_embeddable/public/embeddable/index.ts similarity index 100% rename from src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts rename to src/plugins/navigation_embeddable/public/embeddable/index.ts diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx similarity index 100% rename from src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx rename to src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.tsx similarity index 92% rename from src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts rename to src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.tsx index 54f8ecbb9f892..3bdb38511838e 100644 --- a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import type { EmbeddableInput, IContainer } from '@kbn/embeddable-plugin/public'; import { EmbeddableFactory, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; -import { NavigationEmbeddable, NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; +import { NAVIGATION_EMBEDDABLE_TYPE, NavigationEmbeddable } from './navigation_embeddable'; export type NavigationEmbeddableFactory = EmbeddableFactory; @@ -26,7 +26,7 @@ export class NavigationEmbeddableFactoryDefinition implements EmbeddableFactoryD public getDisplayName() { return i18n.translate('navigationEmbeddable.navigationEmbeddableFactory.displayName', { - defaultMessage: 'Navigation', + defaultMessage: 'Links', }); } diff --git a/src/plugins/navigation_embeddable/public/index.ts b/src/plugins/navigation_embeddable/public/index.ts index 5e8be17c8958b..9cdbcfcc6c667 100644 --- a/src/plugins/navigation_embeddable/public/index.ts +++ b/src/plugins/navigation_embeddable/public/index.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -export type { NavigationEmbeddableFactory } from './navigation_embeddable'; +export type { NavigationEmbeddableFactory } from './embeddable'; export { NAVIGATION_EMBEDDABLE_TYPE, NavigationEmbeddableFactoryDefinition, NavigationEmbeddable, -} from './navigation_embeddable'; +} from './embeddable'; import { NavigationEmbeddablePlugin } from './plugin'; diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable_services.ts b/src/plugins/navigation_embeddable/public/navigation_embeddable_services.ts new file mode 100644 index 0000000000000..7a2311e412006 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/navigation_embeddable_services.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; + +import { CoreStart } from '@kbn/core/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; + +import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import { StartDependencies as NavigationEmbeddableStartDependencies } from './plugin'; + +export let coreServices: CoreStart; +export let dashboardServices: DashboardStart; +export let contentManagement: ContentManagementPublicStart; + +const servicesReady$ = new BehaviorSubject(false); + +export const untilPluginStartServicesReady = () => { + if (servicesReady$.value) return Promise.resolve(); + return new Promise((resolve) => { + const subscription = servicesReady$.subscribe((isInitialized) => { + if (isInitialized) { + subscription.unsubscribe(); + resolve(); + } + }); + }); +}; + +export const setKibanaServices = ( + kibanaCore: CoreStart, + deps: NavigationEmbeddableStartDependencies +) => { + coreServices = kibanaCore; + dashboardServices = deps.dashboard; + contentManagement = deps.contentManagement; + + servicesReady$.next(true); +}; diff --git a/src/plugins/navigation_embeddable/public/plugin.ts b/src/plugins/navigation_embeddable/public/plugin.ts index 23dff1f5d1eb8..940c4b037e90a 100644 --- a/src/plugins/navigation_embeddable/public/plugin.ts +++ b/src/plugins/navigation_embeddable/public/plugin.ts @@ -7,16 +7,32 @@ */ import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { + ContentManagementPublicSetup, + ContentManagementPublicStart, +} from '@kbn/content-management-plugin/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import { NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; -import { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable'; +import { NAVIGATION_EMBEDDABLE_TYPE } from './embeddable'; +import { NavigationEmbeddableFactoryDefinition } from './embeddable'; +import { CONTENT_ID, LATEST_VERSION } from '../common/content_management'; +import { APP_NAME } from '../common/constants'; +import { setKibanaServices } from './navigation_embeddable_services'; +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PluginSetup {} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PluginStart {} export interface SetupDependencies { embeddable: EmbeddableSetup; + contentManagement: ContentManagementPublicSetup; } export interface StartDependencies { embeddable: EmbeddableStart; + contentManagement: ContentManagementPublicStart; + dashboard: DashboardStart; } export class NavigationEmbeddablePlugin @@ -29,9 +45,18 @@ export class NavigationEmbeddablePlugin NAVIGATION_EMBEDDABLE_TYPE, new NavigationEmbeddableFactoryDefinition() ); + + plugins.contentManagement.registry.register({ + id: CONTENT_ID, + version: { + latest: LATEST_VERSION, + }, + name: APP_NAME, + }); } public start(core: CoreStart, plugins: StartDependencies) { + setKibanaServices(core, plugins); return {}; } diff --git a/src/plugins/navigation_embeddable/server/content_management/index.ts b/src/plugins/navigation_embeddable/server/content_management/index.ts new file mode 100644 index 0000000000000..2376765bcac83 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/content_management/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { NavigationEmbeddableStorage } from './navigation_embeddable_storage'; diff --git a/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts b/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts new file mode 100644 index 0000000000000..c6e1974dc24c3 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SOContentStorage } from '@kbn/content-management-utils'; +import type { NavigationEmbeddableCrudTypes } from '../../common/content_management'; +import { CONTENT_ID } from '../../common'; +import { cmServicesDefinition } from '../../common/content_management/cm_services'; + +export class NavigationEmbeddableStorage extends SOContentStorage { + constructor() { + super({ + savedObjectType: CONTENT_ID, + cmServicesDefinition, + enableMSearch: true, + allowedSavedObjectAttributes: ['title', 'description', 'linkListJSON'], + }); + } +} diff --git a/src/plugins/navigation_embeddable/server/index.ts b/src/plugins/navigation_embeddable/server/index.ts new file mode 100644 index 0000000000000..6ececdd95b5d0 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { NavigationEmbeddableServerPlugin } from './plugin'; + +export const plugin = () => new NavigationEmbeddableServerPlugin(); diff --git a/src/plugins/navigation_embeddable/server/plugin.ts b/src/plugins/navigation_embeddable/server/plugin.ts new file mode 100644 index 0000000000000..9e2d973310eba --- /dev/null +++ b/src/plugins/navigation_embeddable/server/plugin.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; +import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; +import { CONTENT_ID, LATEST_VERSION } from '../common'; +import { NavigationEmbeddableStorage } from './content_management'; + +export class NavigationEmbeddableServerPlugin implements Plugin { + public setup( + core: CoreSetup, + plugins: { + contentManagement: ContentManagementServerSetup; + } + ) { + plugins.contentManagement.register({ + id: CONTENT_ID, + storage: new NavigationEmbeddableStorage(), + version: { + latest: LATEST_VERSION, + }, + }); + + core.savedObjects.registerType({ + name: CONTENT_ID, + hidden: false, + hiddenFromHttpApis: true, + namespaceType: 'multiple', + mappings: { + dynamic: false, + properties: { + title: { type: 'text' }, + description: { type: 'text' }, + linkListJSON: { type: 'text' }, + }, + }, + }); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} From e36e4e6501d749b4d98ca2eec3fc0592b320a164 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 29 Jun 2023 10:07:29 -0400 Subject: [PATCH 03/53] Revert "Initialize content management" This reverts commit dcc37c65a87d5ff255e8cd3850deda296f73ba9f. --- .../navigation_embeddable/common/constants.ts | 17 --- .../common/content_management/cm_services.ts | 21 ---- .../common/content_management/index.ts | 19 ---- .../common/content_management/latest.ts | 9 -- .../content_management/v1/cm_services.ts | 100 ------------------ .../common/content_management/v1/index.ts | 11 -- .../common/content_management/v1/types.ts | 32 ------ .../navigation_embeddable/common/index.ts | 9 -- .../navigation_embeddable/common/types.ts | 9 -- .../navigation_embeddable/kibana.jsonc | 8 +- .../public/content_management/index.ts | 9 -- ...embeddable_content_management_client.ts.ts | 80 -------------- .../navigation_embeddable/public/index.ts | 4 +- .../index.ts | 0 .../navigation_embeddable.tsx | 0 .../navigation_embeddable_factory.ts} | 4 +- .../public/navigation_embeddable_services.ts | 44 -------- .../navigation_embeddable/public/plugin.ts | 29 +---- .../server/content_management/index.ts | 9 -- .../navigation_embeddable_storage.ts | 23 ---- .../navigation_embeddable/server/index.ts | 11 -- .../navigation_embeddable/server/plugin.ts | 52 --------- 22 files changed, 8 insertions(+), 492 deletions(-) delete mode 100644 src/plugins/navigation_embeddable/common/constants.ts delete mode 100644 src/plugins/navigation_embeddable/common/content_management/cm_services.ts delete mode 100644 src/plugins/navigation_embeddable/common/content_management/index.ts delete mode 100644 src/plugins/navigation_embeddable/common/content_management/latest.ts delete mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts delete mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/index.ts delete mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/types.ts delete mode 100644 src/plugins/navigation_embeddable/common/index.ts delete mode 100644 src/plugins/navigation_embeddable/common/types.ts delete mode 100644 src/plugins/navigation_embeddable/public/content_management/index.ts delete mode 100644 src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts rename src/plugins/navigation_embeddable/public/{embeddable => navigation_embeddable}/index.ts (100%) rename src/plugins/navigation_embeddable/public/{embeddable => navigation_embeddable}/navigation_embeddable.tsx (100%) rename src/plugins/navigation_embeddable/public/{embeddable/navigation_embeddable_factory.tsx => navigation_embeddable/navigation_embeddable_factory.ts} (92%) delete mode 100644 src/plugins/navigation_embeddable/public/navigation_embeddable_services.ts delete mode 100644 src/plugins/navigation_embeddable/server/content_management/index.ts delete mode 100644 src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts delete mode 100644 src/plugins/navigation_embeddable/server/index.ts delete mode 100644 src/plugins/navigation_embeddable/server/plugin.ts diff --git a/src/plugins/navigation_embeddable/common/constants.ts b/src/plugins/navigation_embeddable/common/constants.ts deleted file mode 100644 index e71ee4836638b..0000000000000 --- a/src/plugins/navigation_embeddable/common/constants.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; - -export const LATEST_VERSION = 1; - -export const CONTENT_ID = 'navigationEmbeddable'; - -export const APP_NAME = i18n.translate('xpack.maps.visTypeAlias.title', { - defaultMessage: 'Links', -}); diff --git a/src/plugins/navigation_embeddable/common/content_management/cm_services.ts b/src/plugins/navigation_embeddable/common/content_management/cm_services.ts deleted file mode 100644 index af32d57e0437f..0000000000000 --- a/src/plugins/navigation_embeddable/common/content_management/cm_services.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import type { - ContentManagementServicesDefinition as ServicesDefinition, - Version, -} from '@kbn/object-versioning'; - -// We export the versionned service definition from this file and not the barrel to avoid adding -// the schemas in the "public" js bundle - -import { serviceDefinition as v1 } from './v1/cm_services'; - -export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = { - 1: v1, -}; diff --git a/src/plugins/navigation_embeddable/common/content_management/index.ts b/src/plugins/navigation_embeddable/common/content_management/index.ts deleted file mode 100644 index 3c5ca47d812c4..0000000000000 --- a/src/plugins/navigation_embeddable/common/content_management/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { LATEST_VERSION, CONTENT_ID } from '../constants'; - -export type { NavigationEmbeddableContentType } from '../types'; - -export type { - NavigationEmbeddableCrudTypes, - NavigationEmbeddableAttributes, - NavigationEmbeddableItem, -} from './latest'; - -export * as NavigationEmbeddableV1 from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/latest.ts b/src/plugins/navigation_embeddable/common/content_management/latest.ts deleted file mode 100644 index e9c79f0f50f93..0000000000000 --- a/src/plugins/navigation_embeddable/common/content_management/latest.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export * from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts deleted file mode 100644 index 66d1df1454892..0000000000000 --- a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { schema } from '@kbn/config-schema'; -import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning'; -import { - savedObjectSchema, - objectTypeToGetResultSchema, - createOptionsSchemas, - updateOptionsSchema, - createResultSchema, -} from '@kbn/content-management-utils'; - -const navigationEmbeddableAttributesSchema = schema.object( - { - title: schema.string(), - description: schema.maybe(schema.string()), - linkListJSON: schema.maybe(schema.string()), - }, - { unknowns: 'forbid' } -); - -const navigationEmbeddableSavedObjectSchema = savedObjectSchema( - navigationEmbeddableAttributesSchema -); - -const searchOptionsSchema = schema.maybe( - schema.object( - { - onlyTitle: schema.maybe(schema.boolean()), - }, - { unknowns: 'forbid' } - ) -); - -const navigationEmbeddableCreateOptionsSchema = schema.object({ - id: createOptionsSchemas.id, - references: schema.maybe(createOptionsSchemas.references), - overwrite: createOptionsSchemas.overwrite, -}); - -const navigationEmbeddableUpdateOptionsSchema = schema.object({ - references: updateOptionsSchema.references, -}); - -// Content management service definition. -// We need it for BWC support between different versions of the content -export const serviceDefinition: ServicesDefinition = { - get: { - out: { - result: { - schema: objectTypeToGetResultSchema(navigationEmbeddableSavedObjectSchema), - }, - }, - }, - create: { - in: { - options: { - schema: navigationEmbeddableCreateOptionsSchema, - }, - data: { - schema: navigationEmbeddableSavedObjectSchema, - }, - }, - out: { - result: { - schema: createResultSchema(navigationEmbeddableSavedObjectSchema), - }, - }, - }, - update: { - in: { - options: { - schema: navigationEmbeddableUpdateOptionsSchema, // same schema as "create" - }, - data: { - schema: navigationEmbeddableSavedObjectSchema, - }, - }, - }, - search: { - in: { - options: { - schema: searchOptionsSchema, - }, - }, - }, - mSearch: { - out: { - result: { - schema: navigationEmbeddableSavedObjectSchema, - }, - }, - }, -}; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts deleted file mode 100644 index 8ef0422178d54..0000000000000 --- a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { NavigationEmbeddableCrudTypes } from './types'; -export type { NavigationEmbeddableCrudTypes, NavigationEmbeddableAttributes } from './types'; -export type NavigationEmbeddableItem = NavigationEmbeddableCrudTypes['Item']; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts deleted file mode 100644 index 00a989292f933..0000000000000 --- a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import type { - ContentManagementCrudTypes, - SavedObjectCreateOptions, - SavedObjectUpdateOptions, -} from '@kbn/content-management-utils'; -import { NavigationEmbeddableContentType } from '../../types'; - -export type NavigationEmbeddableCrudTypes = ContentManagementCrudTypes< - NavigationEmbeddableContentType, - NavigationEmbeddableAttributes, - Pick, - Pick, - { - /** Flag to indicate to only search the text on the "title" field */ - onlyTitle?: boolean; - } ->; - -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type NavigationEmbeddableAttributes = { - title: string; - description?: string; - linkListJSON?: string; -}; diff --git a/src/plugins/navigation_embeddable/common/index.ts b/src/plugins/navigation_embeddable/common/index.ts deleted file mode 100644 index 1c5c974297430..0000000000000 --- a/src/plugins/navigation_embeddable/common/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { CONTENT_ID, LATEST_VERSION } from './constants'; diff --git a/src/plugins/navigation_embeddable/common/types.ts b/src/plugins/navigation_embeddable/common/types.ts deleted file mode 100644 index 2038a5c0f04c1..0000000000000 --- a/src/plugins/navigation_embeddable/common/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export type NavigationEmbeddableContentType = 'navigationEmbeddable'; diff --git a/src/plugins/navigation_embeddable/kibana.jsonc b/src/plugins/navigation_embeddable/kibana.jsonc index d0b8f4d43c9eb..76d185c560084 100644 --- a/src/plugins/navigation_embeddable/kibana.jsonc +++ b/src/plugins/navigation_embeddable/kibana.jsonc @@ -5,14 +5,10 @@ "description": "An embeddable for quickly navigating between dashboards.", "plugin": { "id": "navigationEmbeddable", - "server": true, + "server": false, "browser": true, "requiredPlugins": [ - "contentManagement", - "dashboard", - "embeddable", - "kibanaReact", - "presentationUtil" + "embeddable" ], "optionalPlugins": [], "requiredBundles": [ diff --git a/src/plugins/navigation_embeddable/public/content_management/index.ts b/src/plugins/navigation_embeddable/public/content_management/index.ts deleted file mode 100644 index abdf2fd0159eb..0000000000000 --- a/src/plugins/navigation_embeddable/public/content_management/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { navigationEmbeddableClient } from './navigation_embeddable_content_management_client.ts'; diff --git a/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts b/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts deleted file mode 100644 index 800c7ff5eb59f..0000000000000 --- a/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import type { SearchQuery } from '@kbn/content-management-plugin/common'; -import type { NavigationEmbeddableCrudTypes } from '../../common/content_management'; -import { CONTENT_ID as contentTypeId } from '../../common'; -import { contentManagement } from '../navigation_embeddable_services'; - -const get = async (id: string) => { - return contentManagement.client.get({ contentTypeId, id }); -}; - -const create = async ({ - data, - options, -}: Omit) => { - const res = await contentManagement.client.create< - NavigationEmbeddableCrudTypes['CreateIn'], - NavigationEmbeddableCrudTypes['CreateOut'] - >({ - contentTypeId, - data, - options, - }); - return res; -}; - -const update = async ({ - id, - data, - options, -}: Omit) => { - const res = await contentManagement.client.update< - NavigationEmbeddableCrudTypes['UpdateIn'], - NavigationEmbeddableCrudTypes['UpdateOut'] - >({ - contentTypeId, - id, - data, - options, - }); - return res; -}; - -const deleteNavigationEmbeddable = async (id: string) => { - await contentManagement.client.delete< - NavigationEmbeddableCrudTypes['DeleteIn'], - NavigationEmbeddableCrudTypes['DeleteOut'] - >({ - contentTypeId, - id, - }); -}; - -const search = async ( - query: SearchQuery = {}, - options?: NavigationEmbeddableCrudTypes['SearchOptions'] -) => { - return contentManagement.client.search< - NavigationEmbeddableCrudTypes['SearchIn'], - NavigationEmbeddableCrudTypes['SearchOut'] - >({ - contentTypeId, - query, - options, - }); -}; - -export const navigationEmbeddableClient = { - get, - create, - update, - delete: deleteNavigationEmbeddable, - search, -}; diff --git a/src/plugins/navigation_embeddable/public/index.ts b/src/plugins/navigation_embeddable/public/index.ts index 9cdbcfcc6c667..5e8be17c8958b 100644 --- a/src/plugins/navigation_embeddable/public/index.ts +++ b/src/plugins/navigation_embeddable/public/index.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -export type { NavigationEmbeddableFactory } from './embeddable'; +export type { NavigationEmbeddableFactory } from './navigation_embeddable'; export { NAVIGATION_EMBEDDABLE_TYPE, NavigationEmbeddableFactoryDefinition, NavigationEmbeddable, -} from './embeddable'; +} from './navigation_embeddable'; import { NavigationEmbeddablePlugin } from './plugin'; diff --git a/src/plugins/navigation_embeddable/public/embeddable/index.ts b/src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts similarity index 100% rename from src/plugins/navigation_embeddable/public/embeddable/index.ts rename to src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx b/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx similarity index 100% rename from src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx rename to src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.tsx b/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts similarity index 92% rename from src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.tsx rename to src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts index 3bdb38511838e..54f8ecbb9f892 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.tsx +++ b/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import type { EmbeddableInput, IContainer } from '@kbn/embeddable-plugin/public'; import { EmbeddableFactory, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; -import { NAVIGATION_EMBEDDABLE_TYPE, NavigationEmbeddable } from './navigation_embeddable'; +import { NavigationEmbeddable, NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; export type NavigationEmbeddableFactory = EmbeddableFactory; @@ -26,7 +26,7 @@ export class NavigationEmbeddableFactoryDefinition implements EmbeddableFactoryD public getDisplayName() { return i18n.translate('navigationEmbeddable.navigationEmbeddableFactory.displayName', { - defaultMessage: 'Links', + defaultMessage: 'Navigation', }); } diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable_services.ts b/src/plugins/navigation_embeddable/public/navigation_embeddable_services.ts deleted file mode 100644 index 7a2311e412006..0000000000000 --- a/src/plugins/navigation_embeddable/public/navigation_embeddable_services.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { BehaviorSubject } from 'rxjs'; - -import { CoreStart } from '@kbn/core/public'; -import { DashboardStart } from '@kbn/dashboard-plugin/public'; - -import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; -import { StartDependencies as NavigationEmbeddableStartDependencies } from './plugin'; - -export let coreServices: CoreStart; -export let dashboardServices: DashboardStart; -export let contentManagement: ContentManagementPublicStart; - -const servicesReady$ = new BehaviorSubject(false); - -export const untilPluginStartServicesReady = () => { - if (servicesReady$.value) return Promise.resolve(); - return new Promise((resolve) => { - const subscription = servicesReady$.subscribe((isInitialized) => { - if (isInitialized) { - subscription.unsubscribe(); - resolve(); - } - }); - }); -}; - -export const setKibanaServices = ( - kibanaCore: CoreStart, - deps: NavigationEmbeddableStartDependencies -) => { - coreServices = kibanaCore; - dashboardServices = deps.dashboard; - contentManagement = deps.contentManagement; - - servicesReady$.next(true); -}; diff --git a/src/plugins/navigation_embeddable/public/plugin.ts b/src/plugins/navigation_embeddable/public/plugin.ts index 940c4b037e90a..23dff1f5d1eb8 100644 --- a/src/plugins/navigation_embeddable/public/plugin.ts +++ b/src/plugins/navigation_embeddable/public/plugin.ts @@ -7,32 +7,16 @@ */ import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; -import { - ContentManagementPublicSetup, - ContentManagementPublicStart, -} from '@kbn/content-management-plugin/public'; -import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import { NAVIGATION_EMBEDDABLE_TYPE } from './embeddable'; -import { NavigationEmbeddableFactoryDefinition } from './embeddable'; -import { CONTENT_ID, LATEST_VERSION } from '../common/content_management'; -import { APP_NAME } from '../common/constants'; -import { setKibanaServices } from './navigation_embeddable_services'; +import { NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; +import { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PluginSetup {} - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PluginStart {} export interface SetupDependencies { embeddable: EmbeddableSetup; - contentManagement: ContentManagementPublicSetup; } export interface StartDependencies { embeddable: EmbeddableStart; - contentManagement: ContentManagementPublicStart; - dashboard: DashboardStart; } export class NavigationEmbeddablePlugin @@ -45,18 +29,9 @@ export class NavigationEmbeddablePlugin NAVIGATION_EMBEDDABLE_TYPE, new NavigationEmbeddableFactoryDefinition() ); - - plugins.contentManagement.registry.register({ - id: CONTENT_ID, - version: { - latest: LATEST_VERSION, - }, - name: APP_NAME, - }); } public start(core: CoreStart, plugins: StartDependencies) { - setKibanaServices(core, plugins); return {}; } diff --git a/src/plugins/navigation_embeddable/server/content_management/index.ts b/src/plugins/navigation_embeddable/server/content_management/index.ts deleted file mode 100644 index 2376765bcac83..0000000000000 --- a/src/plugins/navigation_embeddable/server/content_management/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { NavigationEmbeddableStorage } from './navigation_embeddable_storage'; diff --git a/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts b/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts deleted file mode 100644 index c6e1974dc24c3..0000000000000 --- a/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { SOContentStorage } from '@kbn/content-management-utils'; -import type { NavigationEmbeddableCrudTypes } from '../../common/content_management'; -import { CONTENT_ID } from '../../common'; -import { cmServicesDefinition } from '../../common/content_management/cm_services'; - -export class NavigationEmbeddableStorage extends SOContentStorage { - constructor() { - super({ - savedObjectType: CONTENT_ID, - cmServicesDefinition, - enableMSearch: true, - allowedSavedObjectAttributes: ['title', 'description', 'linkListJSON'], - }); - } -} diff --git a/src/plugins/navigation_embeddable/server/index.ts b/src/plugins/navigation_embeddable/server/index.ts deleted file mode 100644 index 6ececdd95b5d0..0000000000000 --- a/src/plugins/navigation_embeddable/server/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { NavigationEmbeddableServerPlugin } from './plugin'; - -export const plugin = () => new NavigationEmbeddableServerPlugin(); diff --git a/src/plugins/navigation_embeddable/server/plugin.ts b/src/plugins/navigation_embeddable/server/plugin.ts deleted file mode 100644 index 9e2d973310eba..0000000000000 --- a/src/plugins/navigation_embeddable/server/plugin.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; -import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; -import { CONTENT_ID, LATEST_VERSION } from '../common'; -import { NavigationEmbeddableStorage } from './content_management'; - -export class NavigationEmbeddableServerPlugin implements Plugin { - public setup( - core: CoreSetup, - plugins: { - contentManagement: ContentManagementServerSetup; - } - ) { - plugins.contentManagement.register({ - id: CONTENT_ID, - storage: new NavigationEmbeddableStorage(), - version: { - latest: LATEST_VERSION, - }, - }); - - core.savedObjects.registerType({ - name: CONTENT_ID, - hidden: false, - hiddenFromHttpApis: true, - namespaceType: 'multiple', - mappings: { - dynamic: false, - properties: { - title: { type: 'text' }, - description: { type: 'text' }, - linkListJSON: { type: 'text' }, - }, - }, - }); - - return {}; - } - - public start(core: CoreStart) { - return {}; - } - - public stop() {} -} From 3c2e8f64425aa9c603dfbda95402f6cfd1fb448f Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 10 Jul 2023 09:10:05 -0600 Subject: [PATCH 04/53] [Dashboard Navigation] Add creation UI (#160179) Closes https://github.com/elastic/kibana/issues/154360 ## Summary This PR adds the first draft of the UI for creating the navigation embeddable and adding links to it. Note that this PR **only** addresses adding links - you cannot currently remove links from the panel or edit existing links. Also, because this PR contains critical code, some cleanup tasks were left as `TODO` in order to get it merged ASAP - these will be addressed in follow up PRs. https://github.com/elastic/kibana/assets/8698078/ac011207-1c4b-40cd-a151-78addb99d32d ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] ~[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~ Will be addressed in https://github.com/elastic/kibana/issues/161287 - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../top_nav/dashboard_editing_toolbar.tsx | 2 +- .../dashboard_content_management_service.ts | 3 +- .../lib/find_dashboards.ts | 3 + .../dashboard_content_management/types.ts | 5 +- .../public/lib/actions/edit_panel_action.ts | 2 +- .../public/lib/containers/container.ts | 3 +- .../lib/embeddables/embeddable_factory.ts | 5 +- .../public/lib/panel/embeddable_panel.tsx | 19 +- .../navigation_embeddable/kibana.jsonc | 11 +- .../navigation_embeddable/public/_mixins.scss | 25 +++ .../dashboard_link_component.tsx | 54 +++++ .../dashboard_link_destination_picker.tsx | 118 +++++++++++ .../dashboard_link/dashboard_link_strings.ts | 32 +++ .../dashboard_link/dashboard_link_tools.tsx | 89 ++++++++ .../external_link/external_link_component.tsx | 24 +++ .../external_link_destination_picker.tsx | 45 ++++ .../external_link/external_link_strings.ts | 24 +++ .../components/navigation_embeddable.scss | 23 +++ .../navigation_embeddable_component.tsx | 44 ++++ .../navigation_embeddable_link_editor.tsx | 175 ++++++++++++++++ .../navigation_embeddable_panel_editor.tsx | 192 ++++++++++++++++++ .../navigation_embeddable_strings.ts | 58 ++++++ .../public/editor/open_editor_flyout.tsx | 70 +++++++ .../index.ts | 5 +- .../embeddable/navigation_embeddable.tsx | 97 +++++++++ .../navigation_embeddable_factory.ts | 102 ++++++++++ .../navigation_embeddable_reducers.ts | 27 +++ .../public/embeddable/types.ts | 70 +++++++ .../navigation_embeddable/public/index.ts | 4 +- .../navigation_embeddable.tsx | 32 --- .../navigation_embeddable_factory.ts | 36 ---- .../navigation_embeddable/public/plugin.ts | 39 ++-- .../public/services/kibana_services.ts | 41 ++++ .../navigation_embeddable/tsconfig.json | 13 +- 34 files changed, 1380 insertions(+), 112 deletions(-) create mode 100644 src/plugins/navigation_embeddable/public/_mixins.scss create mode 100644 src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx create mode 100644 src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx create mode 100644 src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts create mode 100644 src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx create mode 100644 src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx create mode 100644 src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx create mode 100644 src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts create mode 100644 src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss create mode 100644 src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx create mode 100644 src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx create mode 100644 src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx create mode 100644 src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts create mode 100644 src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx rename src/plugins/navigation_embeddable/public/{navigation_embeddable => embeddable}/index.ts (81%) create mode 100644 src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx create mode 100644 src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts create mode 100644 src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts create mode 100644 src/plugins/navigation_embeddable/public/embeddable/types.ts delete mode 100644 src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx delete mode 100644 src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts create mode 100644 src/plugins/navigation_embeddable/public/services/kibana_services.ts diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx index 708af176d785d..05962d387e476 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx @@ -101,7 +101,7 @@ export function DashboardEditingToolbar() { let explicitInput: Awaited>; try { - explicitInput = await embeddableFactory.getExplicitInput(); + explicitInput = await embeddableFactory.getExplicitInput(undefined, dashboard); } catch (e) { // error likely means user canceled embeddable creation return; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts index 5a5c67c798606..f16bd4442f2c1 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts @@ -65,11 +65,12 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen dashboardSessionStorage, }), findDashboards: { - search: ({ hasReference, hasNoReference, search, size }) => + search: ({ hasReference, hasNoReference, search, size, options }) => searchDashboards({ contentManagement, hasNoReference, hasReference, + options, search, size, }), diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts index 41bdd60d6c1d7..49ffee54d536b 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts @@ -18,6 +18,7 @@ import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; export interface SearchDashboardsArgs { contentManagement: DashboardStartDependencies['contentManagement']; + options?: DashboardCrudTypes['SearchIn']['options']; hasNoReference?: SavedObjectsFindOptionsReference[]; hasReference?: SavedObjectsFindOptionsReference[]; search: string; @@ -33,6 +34,7 @@ export async function searchDashboards({ contentManagement, hasNoReference, hasReference, + options, search, size, }: SearchDashboardsArgs): Promise { @@ -52,6 +54,7 @@ export async function searchDashboards({ excluded: (hasNoReference ?? []).map(({ id }) => id), }, }, + options, }); return { total, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts index 92fbe3005e9e4..858d5800961b5 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts @@ -87,7 +87,10 @@ export interface SaveDashboardReturn { */ export interface FindDashboardsService { search: ( - props: Pick + props: Pick< + SearchDashboardsArgs, + 'hasReference' | 'hasNoReference' | 'search' | 'size' | 'options' + > ) => Promise; findByIds: (ids: string[]) => Promise; findByTitle: (title: string) => Promise<{ id: string } | undefined>; diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index ba59d92cbef60..98e541fd08e6f 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -92,7 +92,7 @@ export class EditPanelAction implements Action { } const oldExplicitInput = embeddable.getExplicitInput(); - const newExplicitInput = await factory.getExplicitInput(oldExplicitInput); + const newExplicitInput = await factory.getExplicitInput(oldExplicitInput, embeddable.parent); embeddable.parent?.replaceEmbeddable(embeddable.id, newExplicitInput); return; } diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index a53cfb8725fac..2f29410bc3e59 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -301,7 +301,7 @@ export abstract class Container< public async getExplicitInputIsEqual(lastInput: TContainerInput) { const { panels: lastPanels, ...restOfLastInput } = lastInput; - const { panels: currentPanels, ...restOfCurrentInput } = this.getInput(); + const { panels: currentPanels, ...restOfCurrentInput } = this.getExplicitInput(); const otherInputIsEqual = isEqual(restOfLastInput, restOfCurrentInput); if (!otherInputIsEqual) return false; @@ -371,7 +371,6 @@ export abstract class Container< initializeSettings?: EmbeddableContainerSettings ) { let initializeOrder = Object.keys(initialInput.panels); - if (initializeSettings?.childIdInitializeOrder) { const initializeOrderSet = new Set(); diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts index 0d4aa5f150abc..f785ea9195930 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts @@ -99,7 +99,10 @@ export interface EmbeddableFactory< * * Can be used to edit an embeddable by re-requesting explicit input. Initial input can be provided to allow the editor to show the current state. */ - getExplicitInput(initialInput?: Partial): Promise>; + getExplicitInput( + initialInput?: Partial, + parent?: IContainer + ): Promise>; /** * Creates a new embeddable instance based off the saved object id. diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index c6bef2db73ac9..d964dd8c4b0b4 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -430,27 +430,28 @@ export class EmbeddablePanel extends React.Component { }; private getActionContextMenuPanel = async () => { - let regularActions = + const regularActions = (await this.props.getActions?.(CONTEXT_MENU_TRIGGER, { embeddable: this.props.embeddable, })) ?? []; const { disabledActions } = this.props.embeddable.getInput(); + + let allActions = regularActions.concat( + Object.values(this.state.universalActions ?? {}) as Array> + ); if (disabledActions) { const removeDisabledActions = removeById(disabledActions); - regularActions = regularActions.filter(removeDisabledActions); + allActions = allActions.filter(removeDisabledActions); } - let sortedActions = regularActions - .concat(Object.values(this.state.universalActions || {}) as Array>) - .sort(sortByOrderField); - if (this.props.actionPredicate) { - sortedActions = sortedActions.filter(({ id }) => this.props.actionPredicate!(id)); + allActions = allActions.filter(({ id }) => this.props.actionPredicate!(id)); } + allActions.sort(sortByOrderField); const panels = await buildContextMenuForActions({ - actions: sortedActions.map((action) => ({ + actions: allActions.map((action) => ({ action, context: { embeddable: this.props.embeddable }, trigger: contextMenuTrigger, @@ -460,7 +461,7 @@ export class EmbeddablePanel extends React.Component { return { panels, - actions: sortedActions, + actions: allActions, }; }; } diff --git a/src/plugins/navigation_embeddable/kibana.jsonc b/src/plugins/navigation_embeddable/kibana.jsonc index 76d185c560084..961aacc7641aa 100644 --- a/src/plugins/navigation_embeddable/kibana.jsonc +++ b/src/plugins/navigation_embeddable/kibana.jsonc @@ -1,17 +1,14 @@ { "type": "plugin", - "id": "@kbn/navigation-embeddable-plugin", "owner": "@elastic/kibana-presentation", + "id": "@kbn/navigation-embeddable-plugin", "description": "An embeddable for quickly navigating between dashboards.", "plugin": { "id": "navigationEmbeddable", "server": false, "browser": true, - "requiredPlugins": [ - "embeddable" - ], - "optionalPlugins": [], - "requiredBundles": [ - ] + "requiredPlugins": ["dashboard", "embeddable", "kibanaReact", "presentationUtil"], + "optionalPlugins": ["triggersActionsUi"], + "requiredBundles": [] } } diff --git a/src/plugins/navigation_embeddable/public/_mixins.scss b/src/plugins/navigation_embeddable/public/_mixins.scss new file mode 100644 index 0000000000000..47249b0aa42d4 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/_mixins.scss @@ -0,0 +1,25 @@ +@import '../../../core/public/mixins'; + +@keyframes euiFlyoutAnimation { + 0% { + opacity: 0; + transform: translateX(100%); + } + + 100% { + opacity: 1; + transform: translateX(0%); + } +} + +@mixin euiFlyout { + @include kibanaFullBodyHeight(); + border-left: $euiBorderThin; + position: fixed; + width: 50%; + z-index: $euiZFlyout; + background: $euiColorEmptyShade; + display: flex; + flex-direction: column; + align-items: stretch; +} \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx new file mode 100644 index 0000000000000..7c8170674d9ea --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { EuiButtonEmpty } from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { + DASHBOARD_LINK_TYPE, + NavigationEmbeddableLink, + NavigationLinkInfo, +} from '../../embeddable/types'; +import { fetchDashboard } from './dashboard_link_tools'; +import { useNavigationEmbeddable } from '../../embeddable/navigation_embeddable'; + +export const DashboardLinkComponent = ({ link }: { link: NavigationEmbeddableLink }) => { + const navEmbeddable = useNavigationEmbeddable(); + + const dashboardContainer = navEmbeddable.parent as DashboardContainer; + const parentDashboardTitle = dashboardContainer.select((state) => state.explicitInput.title); + const parentDashboardId = dashboardContainer.select((state) => state.componentState.lastSavedId); + + const { loading: loadingDestinationDashboard, value: destinationDashboard } = + useAsync(async () => { + return await fetchDashboard(link.destination); + }, [link, parentDashboardId]); + + return ( + {}, // TODO: As part of https://github.com/elastic/kibana/issues/154381, connect to drilldown + })} + > + {link.label || + (link.destination === parentDashboardId + ? parentDashboardTitle + : destinationDashboard?.attributes.title)} + + ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx new file mode 100644 index 0000000000000..715434dc2c80f --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { debounce } from 'lodash'; +import useAsync from 'react-use/lib/useAsync'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { + EuiBadge, + EuiSpacer, + EuiHighlight, + EuiSelectable, + EuiFieldSearch, + EuiSelectableOption, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { DashboardItem } from '../../embeddable/types'; +import { memoizedFetchDashboards } from './dashboard_link_tools'; +import { DashboardLinkEmbeddableStrings } from './dashboard_link_strings'; + +export const DashboardLinkDestinationPicker = ({ + setDestination, + setPlaceholder, + currentDestination, + parentDashboard, + ...other +}: { + setDestination: (destination?: string) => void; + setPlaceholder: (placeholder?: string) => void; + currentDestination?: string; + parentDashboard?: DashboardContainer; +}) => { + const [searchString, setSearchString] = useState(''); + const [selectedDashboard, setSelectedDashboard] = useState(); + const [dashboardListOptions, setDashboardListOptions] = useState([]); + + const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId); + + const { loading: loadingDashboardList, value: dashboardList } = useAsync(async () => { + return await memoizedFetchDashboards(searchString, undefined, parentDashboardId); + }, [searchString, parentDashboardId]); + + useEffect(() => { + const dashboardOptions = + (dashboardList ?? []).map((dashboard: DashboardItem) => { + return { + data: dashboard, + label: dashboard.attributes.title, + ...(dashboard.id === parentDashboardId + ? { + prepend: ( + {DashboardLinkEmbeddableStrings.getCurrentDashboardLabel()} + ), + } + : {}), + } as EuiSelectableOption; + }) ?? []; + + setDashboardListOptions(dashboardOptions); + }, [dashboardList, parentDashboardId, searchString]); + + const debouncedSetSearch = useMemo( + () => + debounce((newSearch: string) => { + setSearchString(newSearch); + }, 250), + [setSearchString] + ); + + useEffect(() => { + if (selectedDashboard) { + setDestination(selectedDashboard.id); + setPlaceholder(selectedDashboard.attributes.title); + } else { + setDestination(undefined); + setPlaceholder(undefined); + } + }, [selectedDashboard, setDestination, setPlaceholder]); + + /* {...other} is needed so all inner elements are treated as part of the form */ + return ( +
+ { + debouncedSetSearch(e.target.value); + }} + /> + + { + if (selected.checked) { + setSelectedDashboard(selected.data as DashboardItem); + } else { + setSelectedDashboard(undefined); + } + setDashboardListOptions(newOptions); + }} + renderOption={(option) => { + return {option.label}; + }} + > + {(list) => list} + +
+ ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts new file mode 100644 index 0000000000000..9bc2e2d40f0b0 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const DashboardLinkEmbeddableStrings = { + getDisplayName: () => + i18n.translate('navigationEmbeddable.dashboardLink.displayName', { + defaultMessage: 'Dashboard', + }), + getDescription: () => + i18n.translate('navigationEmbeddable.dsahboardLink.description', { + defaultMessage: 'Go to dashboard', + }), + getSearchPlaceholder: () => + i18n.translate('navigationEmbeddable.dashboardLink.editor.searchPlaceholder', { + defaultMessage: 'Search for a dashboard', + }), + getDashboardPickerAriaLabel: () => + i18n.translate('navigationEmbeddable.dashboardLink.editor.dashboardPickerAriaLabel', { + defaultMessage: 'Pick a destination dashboard', + }), + getCurrentDashboardLabel: () => + i18n.translate('navigationEmbeddable.dashboardLink.editor.currentDashboardLabel', { + defaultMessage: 'Current', + }), +}; diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx new file mode 100644 index 0000000000000..3735e5a044ffa --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isEmpty, memoize } from 'lodash'; +import { DashboardItem } from '../../embeddable/types'; + +import { dashboardServices } from '../../services/kibana_services'; + +/** + * Memoized fetch dashboard will only refetch the dashboard information if the given `dashboardId` changed between + * calls; otherwise, it will use the cached dashboard, which may not take into account changes to the dashboard's title + * description, etc. Be mindful when choosing the memoized version. + */ +export const memoizedFetchDashboard = memoize( + async (dashboardId: string) => { + return await fetchDashboard(dashboardId); + }, + (dashboardId) => { + return dashboardId; + } +); + +export const fetchDashboard = async (dashboardId: string): Promise => { + const findDashboardsService = await dashboardServices.findDashboardsService(); + const response = (await findDashboardsService.findByIds([dashboardId]))[0]; + if (response.status === 'error') { + throw new Error('failure'); // TODO: better error handling + } + return response; +}; + +export const memoizedFetchDashboards = memoize( + async (search: string = '', size: number = 10, currentDashboardId?: string) => { + return await fetchDashboards(search, size, currentDashboardId); + }, + (search, size, currentDashboardId) => { + return [search, size, currentDashboardId].join('|'); + } +); + +const fetchDashboards = async ( + search: string = '', + size: number = 10, + currentDashboardId?: string +): Promise => { + const findDashboardsService = await dashboardServices.findDashboardsService(); + const responses = await findDashboardsService.search({ + search, + size, + options: { onlyTitle: true }, + }); + + let currentDashboard: DashboardItem | undefined; + let dashboardList: DashboardItem[] = responses.hits; + + /** When the parent dashboard has been saved (i.e. it has an ID) and there is no search string ... */ + if (currentDashboardId && isEmpty(search)) { + /** ...force the current dashboard (if it is present in the original search results) to the top of the list */ + dashboardList = dashboardList.sort((dashboard) => { + const isCurrentDashboard = dashboard.id === currentDashboardId; + if (isCurrentDashboard) { + currentDashboard = dashboard; + } + return isCurrentDashboard ? -1 : 1; + }); + + /** + * If the current dashboard wasn't returned in the original search, perform another search to find it and + * force it to the front of the list + */ + if (!currentDashboard) { + currentDashboard = await fetchDashboard(currentDashboardId); + dashboardList.pop(); // the result should still be of `size,` so remove the dashboard at the end of the list + dashboardList.unshift(currentDashboard); // in order to force the current dashboard to the start of the list + } + } + + /** Then, only return the parts of the dashboard object that we need */ + const simplifiedDashboardList = dashboardList.map((hit) => { + return { id: hit.id, attributes: hit.attributes }; + }); + + return simplifiedDashboardList; +}; diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx new file mode 100644 index 0000000000000..90bf4066d4c20 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { EuiButtonEmpty } from '@elastic/eui'; +import { + EXTERNAL_LINK_TYPE, + NavigationLinkInfo, + NavigationEmbeddableLink, +} from '../../embeddable/types'; + +export const ExternalLinkComponent = ({ link }: { link: NavigationEmbeddableLink }) => { + return ( + + {link.label || link.destination} + + ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx b/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx new file mode 100644 index 0000000000000..596ce183d696b --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { EuiFieldText } from '@elastic/eui'; +import { ExternalLinkEmbeddableStrings } from './external_link_strings'; + +// TODO: As part of https://github.com/elastic/kibana/issues/154381, replace this regex URL check with more robust url validation +const isValidUrl = + /^https?:\/\/(?:www.)?[-a-zA-Z0-9@:%._+~#=]{1,256}.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/; + +export const ExternalLinkDestinationPicker = ({ + setDestination, + setPlaceholder, + currentDestination, + ...other +}: { + setDestination: (destination?: string) => void; + setPlaceholder: (placeholder?: string) => void; + currentDestination?: string; +}) => { + const [validUrl, setValidUrl] = useState(true); + + /* {...other} is needed so all inner elements are treated as part of the form */ + return ( +
+ { + const url = e.target.value; + const isValid = isValidUrl.test(url); + setValidUrl(isValid); + setDestination(isValid ? url : undefined); + setPlaceholder(isValid ? url : undefined); + }} + /> +
+ ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts b/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts new file mode 100644 index 0000000000000..77d7b479706b6 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const ExternalLinkEmbeddableStrings = { + getDisplayName: () => + i18n.translate('navigationEmbeddable.externalLink.displayName', { + defaultMessage: 'URL', + }), + getDescription: () => + i18n.translate('navigationEmbeddable.externalLink.description', { + defaultMessage: 'Go to URL', + }), + getPlaceholder: () => + i18n.translate('navigationEmbeddable.externalLink.editor.placeholder', { + defaultMessage: 'Enter external URL', + }), +}; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss b/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss new file mode 100644 index 0000000000000..214cbbc7a8760 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss @@ -0,0 +1,23 @@ +@import '../mixins'; + +.navEmbeddableLinkEditor { + @include euiFlyout; + animation: euiFlyoutAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; + + .linkEditorBackButton { + height: auto; + } +} + +.navEmbeddablePanelEditor { + .linkText { + flex: 1; + min-width: 0; + + .wrapText { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +} \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx new file mode 100644 index 0000000000000..abcbe098f3896 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { EuiPanel } from '@elastic/eui'; + +import { DASHBOARD_LINK_TYPE } from '../embeddable/types'; +import { useNavigationEmbeddable } from '../embeddable/navigation_embeddable'; +import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; +import { ExternalLinkComponent } from './external_link/external_link_component'; + +export const NavigationEmbeddableComponent = () => { + const navEmbeddable = useNavigationEmbeddable(); + + const links = navEmbeddable.select((state) => state.explicitInput.links); + + /** TODO: Render this as a list **or** "tabs" as part of https://github.com/elastic/kibana/issues/154357 */ + return ( + + {Object.keys(links).map((linkId) => { + return ( + + {links[linkId].type === DASHBOARD_LINK_TYPE ? ( + + ) : ( + + )} + + ); + })} + + ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx new file mode 100644 index 0000000000000..a0753130bcc83 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo, useState } from 'react'; + +import { + EuiForm, + EuiIcon, + EuiTitle, + EuiButton, + EuiFormRow, + EuiFlexItem, + EuiFieldText, + EuiFocusTrap, + EuiFlexGroup, + EuiRadioGroup, + EuiFlyoutBody, + EuiButtonEmpty, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiRadioGroupOption, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { + NavigationLinkInfo, + NavigationLinkType, + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + NavigationEmbeddableLink, +} from '../embeddable/types'; +import { NavEmbeddableStrings } from './navigation_embeddable_strings'; +import { ExternalLinkDestinationPicker } from './external_link/external_link_destination_picker'; +import { DashboardLinkDestinationPicker } from './dashboard_link/dashboard_link_destination_picker'; + +export const NavigationEmbeddableLinkEditor = ({ + onSave, + onClose, + parentDashboard, +}: { + onClose: () => void; + parentDashboard?: DashboardContainer; + onSave: (newLink: NavigationEmbeddableLink) => void; +}) => { + const [selectedLinkType, setSelectedLinkType] = useState(DASHBOARD_LINK_TYPE); + const [linkLabel, setLinkLabel] = useState(''); + const [linkDestination, setLinkDestination] = useState(); + const [linkLabelPlaceholder, setLinkLabelPlaceholder] = useState(); + + const linkTypes: EuiRadioGroupOption[] = useMemo(() => { + return ([DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE] as NavigationLinkType[]).map((type) => { + return { + id: type, + label: ( + + + + + {NavigationLinkInfo[type].displayName} + + ), + }; + }); + }, []); + + return ( + + + onClose()} + > + +

{NavEmbeddableStrings.editor.getAddButtonLabel()}

+
+
+
+ + + + { + setLinkDestination(undefined); + setLinkLabelPlaceholder(undefined); + setSelectedLinkType(id as NavigationLinkType); + }} + /> + + + + {selectedLinkType === DASHBOARD_LINK_TYPE ? ( + + ) : ( + + )} + + + + { + setLinkLabel(e.target.value); + }} + /> + + + + {/* TODO: As part of https://github.com/elastic/kibana/issues/154381, we should pull in the custom settings for each link type. + Refer to `x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/collect_config_container.tsx` + for the dashboard drilldown settings, for example. + + Open question: It probably makes sense to re-use these components so any changes made to the drilldown architecture + trickle down to the navigation embeddable - this would require some refactoring, though. Is this a goal for MVP? + */} + + + + + onClose()} iconType="cross"> + {NavEmbeddableStrings.editor.getCancelButtonLabel()} + + + + { + // this check should always be true, since the button is disabled otherwise - this is just for type safety + if (linkDestination) { + onSave({ + destination: linkDestination, + label: linkLabel, + type: selectedLinkType, + }); + onClose(); + } + }} + > + {NavEmbeddableStrings.editor.getAddButtonLabel()} + + + + +
+ ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx new file mode 100644 index 0000000000000..ab24c998feab0 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isEmpty } from 'lodash'; +import { v4 as uuidv4 } from 'uuid'; +import React, { useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { + EuiText, + EuiIcon, + EuiForm, + EuiTitle, + EuiPanel, + IconType, + EuiSpacer, + EuiButton, + EuiFormRow, + EuiFlexItem, + EuiFlexGroup, + EuiFlyoutBody, + EuiButtonEmpty, + EuiFlyoutFooter, + EuiFlyoutHeader, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { + DASHBOARD_LINK_TYPE, + EXTERNAL_LINK_TYPE, + NavigationEmbeddableInput, + NavigationEmbeddableLink, + NavigationLinkInfo, +} from '../embeddable/types'; +import { NavEmbeddableStrings } from './navigation_embeddable_strings'; +import { memoizedFetchDashboard } from './dashboard_link/dashboard_link_tools'; +import { NavigationEmbeddableLinkEditor } from './navigation_embeddable_link_editor'; + +import './navigation_embeddable.scss'; + +export const NavigationEmbeddablePanelEditor = ({ + onSave, + onClose, + initialInput, + parentDashboard, +}: { + onClose: () => void; + initialInput: Partial; + onSave: (input: Partial) => void; + parentDashboard?: DashboardContainer; +}) => { + const [showLinkEditorFlyout, setShowLinkEditorFlyout] = useState(false); + const [links, setLinks] = useState(initialInput.links); + + /** + * TODO: There is probably a more efficient way of storing the dashboard information "temporarily" for any new + * panels and only fetching the dashboard saved objects when first loading this flyout. + * + * Will need to think this through and fix as part of the editing process - not worth holding this PR, since it's + * blocking so much other work :) + */ + const { value: linkList } = useAsync(async () => { + if (!links || isEmpty(links)) return []; + + const newLinks: Array<{ id: string; icon: IconType; label: string }> = await Promise.all( + Object.keys(links).map(async (panelId) => { + let label = links[panelId].label; + let icon = NavigationLinkInfo[EXTERNAL_LINK_TYPE].icon; + + if (links[panelId].type === DASHBOARD_LINK_TYPE) { + icon = NavigationLinkInfo[DASHBOARD_LINK_TYPE].icon; + if (!label) { + const dashboard = await memoizedFetchDashboard(links[panelId].destination); + label = dashboard.attributes.title; + } + } + + return { id: panelId, label: label || links[panelId].destination, icon }; + }) + ); + return newLinks; + }, [links]); + + return ( + <> + + +

{NavEmbeddableStrings.editor.panelEditor.getCreateFlyoutTitle()}

+
+
+ + + + <> + {!links || Object.keys(links).length === 0 ? ( + + + + + {NavEmbeddableStrings.editor.panelEditor.getEmptyLinksMessage()} + + + + + + + setShowLinkEditorFlyout(true)} + iconType="plusInCircle" + > + {NavEmbeddableStrings.editor.getAddButtonLabel()} + + + + + ) : ( + <> + {linkList?.map((link) => { + return ( +
+ + + + + + +
{link.label}
+
+
+
+ +
+ ); + })} + setShowLinkEditorFlyout(true)} + > + {NavEmbeddableStrings.editor.getAddButtonLabel()} + + + )} + +
+
+
+ + + + + {NavEmbeddableStrings.editor.getCancelButtonLabel()} + + + + { + onSave({ ...initialInput, links }); + onClose(); + }} + > + {NavEmbeddableStrings.editor.panelEditor.getSaveButtonLabel()} + + + + + + {showLinkEditorFlyout && ( + { + setShowLinkEditorFlyout(false); + }} + onSave={(newLink: NavigationEmbeddableLink) => { + setLinks({ ...links, [uuidv4()]: newLink }); + }} + parentDashboard={parentDashboard} + /> + )} + + ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts new file mode 100644 index 0000000000000..28faa11a00d23 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const NavEmbeddableStrings = { + editor: { + getAddButtonLabel: () => + i18n.translate('navigationEmbeddable.editor.addButtonLabel', { + defaultMessage: 'Add link', + }), + getCancelButtonLabel: () => + i18n.translate('navigationEmbeddable.editor.cancelButtonLabel', { + defaultMessage: 'Close', + }), + panelEditor: { + getEmptyLinksMessage: () => + i18n.translate('navigationEmbeddable.panelEditor.emptyLinksMessage', { + defaultMessage: "You haven't added any links yet.", + }), + getCreateFlyoutTitle: () => + i18n.translate('navigationEmbeddable.panelEditor.createFlyoutTitle', { + defaultMessage: 'Create links panel', + }), + getSaveButtonLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.saveButtonLabel', { + defaultMessage: 'Save', + }), + }, + linkEditor: { + getGoBackAriaLabel: () => + i18n.translate('navigationEmbeddable.linkEditor.goBackAriaLabel', { + defaultMessage: 'Go back to panel editor.', + }), + getLinkTypePickerLabel: () => + i18n.translate('navigationEmbeddable.linkEditor.linkTypeFormLabel', { + defaultMessage: 'Go to', + }), + getLinkDestinationLabel: () => + i18n.translate('navigationEmbeddable.linkEditor.linkDestinationLabel', { + defaultMessage: 'Choose destination', + }), + getLinkTextLabel: () => + i18n.translate('navigationEmbeddable.linkEditor.linkTextLabel', { + defaultMessage: 'Text (optional)', + }), + getLinkTextPlaceholder: () => + i18n.translate('navigationEmbeddable.linkEditor.linkTextPlaceholder', { + defaultMessage: 'Enter text for link', + }), + }, + }, +}; diff --git a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx new file mode 100644 index 0000000000000..e47ed639f501d --- /dev/null +++ b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Subject } from 'rxjs'; +import { skip, take, takeUntil } from 'rxjs/operators'; + +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { coreServices } from '../services/kibana_services'; +import { NavigationEmbeddableInput } from '../embeddable/types'; +import { NavigationEmbeddablePanelEditor } from '../components/navigation_embeddable_panel_editor'; + +/** + * @throws in case user cancels + */ +export async function openEditorFlyout( + initialInput?: Omit, + parentDashboard?: DashboardContainer +): Promise> { + return new Promise((resolve, reject) => { + const closed$ = new Subject(); + + const onSave = (partialInput: Partial) => { + resolve(partialInput); + editorFlyout.close(); + }; + + const onCancel = () => { + reject(); + editorFlyout.close(); + }; + + // Close the flyout whenever the breadcrumbs change - i.e. when the dashboard's title changes, or when + // the user navigates away from the given dashboard (to the listing page **or** to another app), etc. + coreServices.chrome + .getBreadcrumbs$() + .pipe(takeUntil(closed$), skip(1), take(1)) + .subscribe(() => { + editorFlyout.close(); + }); + + const editorFlyout = coreServices.overlays.openFlyout( + toMountPoint( + , + { theme$: coreServices.theme.theme$ } + ), + { + ownFocus: true, + outsideClickCloses: false, + onClose: onCancel, + } + ); + + editorFlyout.onClose.then(() => { + closed$.next(true); + }); + }); +} diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts b/src/plugins/navigation_embeddable/public/embeddable/index.ts similarity index 81% rename from src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts rename to src/plugins/navigation_embeddable/public/embeddable/index.ts index 1676f521ceb84..12c60f3ebd004 100644 --- a/src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/index.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ -export { NAVIGATION_EMBEDDABLE_TYPE, NavigationEmbeddable } from './navigation_embeddable'; +export { + NAVIGATION_EMBEDDABLE_TYPE, + NavigationEmbeddable as NavigationEmbeddable, +} from './navigation_embeddable'; export type { NavigationEmbeddableFactory } from './navigation_embeddable_factory'; export { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable_factory'; diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx new file mode 100644 index 0000000000000..9f3194d0b376c --- /dev/null +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { createContext, useContext } from 'react'; + +import { Embeddable, EmbeddableOutput } from '@kbn/embeddable-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; + +import { navigationEmbeddableReducers } from './navigation_embeddable_reducers'; +import { NavigationEmbeddableInput, NavigationEmbeddableReduxState } from './types'; +import { NavigationEmbeddableComponent } from '../components/navigation_embeddable_component'; + +export const NAVIGATION_EMBEDDABLE_TYPE = 'navigation'; + +export const NavigationEmbeddableContext = createContext(null); +export const useNavigationEmbeddable = (): NavigationEmbeddable => { + const navigation = useContext(NavigationEmbeddableContext); + if (navigation == null) { + throw new Error('useNavigation must be used inside NavigationEmbeddableContext.'); + } + return navigation!; +}; + +type NavigationReduxEmbeddableTools = ReduxEmbeddableTools< + NavigationEmbeddableReduxState, + typeof navigationEmbeddableReducers +>; + +export interface NavigationEmbeddableConfig { + editable: boolean; +} + +export class NavigationEmbeddable extends Embeddable { + public readonly type = NAVIGATION_EMBEDDABLE_TYPE; + + // state management + public select: NavigationReduxEmbeddableTools['select']; + public getState: NavigationReduxEmbeddableTools['getState']; + public dispatch: NavigationReduxEmbeddableTools['dispatch']; + public onStateChange: NavigationReduxEmbeddableTools['onStateChange']; + + private cleanupStateTools: () => void; + + constructor( + reduxToolsPackage: ReduxToolsPackage, + config: NavigationEmbeddableConfig, + initialInput: NavigationEmbeddableInput, + parent?: DashboardContainer + ) { + super( + initialInput, + { + editable: config.editable, + editableWithExplicitInput: true, + }, + parent + ); + + /** Build redux embeddable tools */ + const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools< + NavigationEmbeddableReduxState, + typeof navigationEmbeddableReducers + >({ + embeddable: this, + reducers: navigationEmbeddableReducers, + initialComponentState: {}, + }); + + this.select = reduxEmbeddableTools.select; + this.getState = reduxEmbeddableTools.getState; + this.dispatch = reduxEmbeddableTools.dispatch; + this.cleanupStateTools = reduxEmbeddableTools.cleanup; + this.onStateChange = reduxEmbeddableTools.onStateChange; + this.setInitializationFinished(); + } + + public async reload() {} + + public destroy() { + super.destroy(); + this.cleanupStateTools(); + } + + public render() { + return ( + + + + ); + } +} diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts new file mode 100644 index 0000000000000..8f9985b687665 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isEmpty } from 'lodash'; + +import { i18n } from '@kbn/i18n'; +import { + ACTION_ADD_PANEL, + EmbeddableFactory, + EmbeddableFactoryDefinition, +} from '@kbn/embeddable-plugin/public'; +import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { NavigationEmbeddableInput } from './types'; +import { NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; +import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; + +export type NavigationEmbeddableFactory = EmbeddableFactory; + +// TODO: Replace string 'OPEN_FLYOUT_ADD_DRILLDOWN' with constant as part of https://github.com/elastic/kibana/issues/154381 +const getDefaultNavigationEmbeddableInput = (): Omit => ({ + links: {}, + disabledActions: [ACTION_ADD_PANEL, 'OPEN_FLYOUT_ADD_DRILLDOWN'], +}); + +export class NavigationEmbeddableFactoryDefinition + implements EmbeddableFactoryDefinition +{ + public readonly type = NAVIGATION_EMBEDDABLE_TYPE; + + public isContainerType = false; + + public async isEditable() { + await untilPluginStartServicesReady(); + return Boolean(coreServices.application.capabilities.dashboard?.showWriteControls); + } + + public canCreateNew() { + return true; + } + + public getDefaultInput(): Partial { + return getDefaultNavigationEmbeddableInput(); + } + + public async create(initialInput: NavigationEmbeddableInput, parent: DashboardContainer) { + if (!initialInput.links || isEmpty(initialInput.links)) { + // don't create an empty navigation embeddable - it should always have at least one link + return; + } + + await untilPluginStartServicesReady(); + + const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage(); + const { NavigationEmbeddable } = await import('./navigation_embeddable'); + const editable = await this.isEditable(); + + return new NavigationEmbeddable( + reduxEmbeddablePackage, + { editable }, + { ...getDefaultNavigationEmbeddableInput(), ...initialInput }, + parent + ); + } + + public async getExplicitInput( + initialInput?: NavigationEmbeddableInput, + parent?: DashboardContainer + ) { + if (!parent) return {}; + + const { openEditorFlyout: createNavigationEmbeddable } = await import( + '../editor/open_editor_flyout' + ); + + const input = await createNavigationEmbeddable( + { ...getDefaultNavigationEmbeddableInput(), ...initialInput }, + parent + ).catch(() => { + // swallow the promise rejection that happens when the flyout is closed + return {}; + }); + + return input; + } + + public getDisplayName() { + return i18n.translate('navigationEmbeddable.navigationEmbeddableFactory.displayName', { + defaultMessage: 'Links', + }); + } + + public getIconType() { + return 'link'; + } +} diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts new file mode 100644 index 0000000000000..29a79c4f6154f --- /dev/null +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { WritableDraft } from 'immer/dist/types/types-external'; + +import { PayloadAction } from '@reduxjs/toolkit'; + +import { NavigationEmbeddableReduxState } from './types'; + +export const navigationEmbeddableReducers = { + /** + * TODO: Right now, we aren't using any reducers - but, I'm keeping this here as a draft + * just in case we need them later on. As a final cleanup, we could remove this if we never + * end up using reducers + */ + setLoading: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.output.loading = action.payload; + }, +}; diff --git a/src/plugins/navigation_embeddable/public/embeddable/types.ts b/src/plugins/navigation_embeddable/public/embeddable/types.ts new file mode 100644 index 0000000000000..40dd5901db948 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/embeddable/types.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; +import { EmbeddableInput, EmbeddableOutput } from '@kbn/embeddable-plugin/public'; + +import { ExternalLinkEmbeddableStrings } from '../components/external_link/external_link_strings'; +import { DashboardLinkEmbeddableStrings } from '../components/dashboard_link/dashboard_link_strings'; + +/** + * Dashboard to dashboard links + */ +export const DASHBOARD_LINK_TYPE = 'dashboardLink'; +export interface DashboardItem { + id: string; + attributes: DashboardAttributes; +} + +/** + * External URL links + */ +export const EXTERNAL_LINK_TYPE = 'externalLink'; + +/** + * Navigation embeddable explicit input + */ +export type NavigationLinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; + +export interface NavigationEmbeddableLink { + type: NavigationLinkType; + destination: string; + // order: number; TODO: Use this as part of https://github.com/elastic/kibana/issues/154361 + label?: string; +} + +export interface NavigationEmbeddableInput extends EmbeddableInput { + links: { [id: string]: NavigationEmbeddableLink }; +} + +export const NavigationLinkInfo: { + [id in NavigationLinkType]: { icon: string; displayName: string; description: string }; +} = { + [DASHBOARD_LINK_TYPE]: { + icon: 'dashboardApp', + displayName: DashboardLinkEmbeddableStrings.getDisplayName(), + description: DashboardLinkEmbeddableStrings.getDescription(), + }, + [EXTERNAL_LINK_TYPE]: { + icon: 'link', + displayName: ExternalLinkEmbeddableStrings.getDisplayName(), + description: ExternalLinkEmbeddableStrings.getDescription(), + }, +}; + +/** + * Navigation embeddable redux state + */ +// export interface NavigationEmbeddableComponentState {} // TODO: Uncomment this if we end up needing component state + +export type NavigationEmbeddableReduxState = ReduxEmbeddableState< + NavigationEmbeddableInput, + EmbeddableOutput, + {} // We currently don't have any component state - TODO: Replace with `NavigationEmbeddableComponentState` if necessary +>; diff --git a/src/plugins/navigation_embeddable/public/index.ts b/src/plugins/navigation_embeddable/public/index.ts index 5e8be17c8958b..9cdbcfcc6c667 100644 --- a/src/plugins/navigation_embeddable/public/index.ts +++ b/src/plugins/navigation_embeddable/public/index.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -export type { NavigationEmbeddableFactory } from './navigation_embeddable'; +export type { NavigationEmbeddableFactory } from './embeddable'; export { NAVIGATION_EMBEDDABLE_TYPE, NavigationEmbeddableFactoryDefinition, NavigationEmbeddable, -} from './navigation_embeddable'; +} from './embeddable'; import { NavigationEmbeddablePlugin } from './plugin'; diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx b/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx deleted file mode 100644 index 2c66f4b655fac..0000000000000 --- a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { EuiTitle } from '@elastic/eui'; -import { Embeddable } from '@kbn/embeddable-plugin/public'; -import type { EmbeddableInput, IContainer } from '@kbn/embeddable-plugin/public'; - -export const NAVIGATION_EMBEDDABLE_TYPE = 'navigation'; - -export class NavigationEmbeddable extends Embeddable { - public readonly type = NAVIGATION_EMBEDDABLE_TYPE; - - constructor(initialInput: EmbeddableInput, parent?: IContainer) { - super(initialInput, {}, parent); - } - - public render(el: HTMLElement) { - return ( - -

Call me Magellan, cuz I'm a navigator!

-
- ); - } - - public reload() {} -} diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts deleted file mode 100644 index 54f8ecbb9f892..0000000000000 --- a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import type { EmbeddableInput, IContainer } from '@kbn/embeddable-plugin/public'; -import { EmbeddableFactory, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; -import { NavigationEmbeddable, NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; - -export type NavigationEmbeddableFactory = EmbeddableFactory; - -export class NavigationEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { - public readonly type = NAVIGATION_EMBEDDABLE_TYPE; - - public async isEditable() { - return true; - } - - public async create(initialInput: EmbeddableInput, parent?: IContainer) { - return new NavigationEmbeddable(initialInput, parent); - } - - public getDisplayName() { - return i18n.translate('navigationEmbeddable.navigationEmbeddableFactory.displayName', { - defaultMessage: 'Navigation', - }); - } - - public getIconType() { - return 'link'; - } -} diff --git a/src/plugins/navigation_embeddable/public/plugin.ts b/src/plugins/navigation_embeddable/public/plugin.ts index 23dff1f5d1eb8..7a969c0298f7a 100644 --- a/src/plugins/navigation_embeddable/public/plugin.ts +++ b/src/plugins/navigation_embeddable/public/plugin.ts @@ -6,33 +6,48 @@ * Side Public License, v 1. */ +import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import { NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; -import { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable'; -export interface SetupDependencies { +import { NAVIGATION_EMBEDDABLE_TYPE } from './embeddable'; +import { setKibanaServices } from './services/kibana_services'; +import { NavigationEmbeddableFactoryDefinition } from './embeddable'; + +export interface NavigationEmbeddableSetupDependencies { embeddable: EmbeddableSetup; } -export interface StartDependencies { +export interface NavigationEmbeddableStartDependencies { embeddable: EmbeddableStart; + dashboard: DashboardStart; } export class NavigationEmbeddablePlugin - implements Plugin + implements + Plugin< + void, + void, + NavigationEmbeddableSetupDependencies, + NavigationEmbeddableStartDependencies + > { constructor() {} - public setup(core: CoreSetup, plugins: SetupDependencies) { - plugins.embeddable.registerEmbeddableFactory( - NAVIGATION_EMBEDDABLE_TYPE, - new NavigationEmbeddableFactoryDefinition() - ); + public setup( + core: CoreSetup, + plugins: NavigationEmbeddableSetupDependencies + ) { + core.getStartServices().then(([_, deps]) => { + plugins.embeddable.registerEmbeddableFactory( + NAVIGATION_EMBEDDABLE_TYPE, + new NavigationEmbeddableFactoryDefinition() + ); + }); } - public start(core: CoreStart, plugins: StartDependencies) { - return {}; + public start(core: CoreStart, plugins: NavigationEmbeddableStartDependencies) { + setKibanaServices(core, plugins); } public stop() {} diff --git a/src/plugins/navigation_embeddable/public/services/kibana_services.ts b/src/plugins/navigation_embeddable/public/services/kibana_services.ts new file mode 100644 index 0000000000000..710c6227a3568 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/services/kibana_services.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; + +import { CoreStart } from '@kbn/core/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; + +import { NavigationEmbeddableStartDependencies } from '../plugin'; + +export let coreServices: CoreStart; +export let dashboardServices: DashboardStart; + +const servicesReady$ = new BehaviorSubject(false); + +export const untilPluginStartServicesReady = () => { + if (servicesReady$.value) return Promise.resolve(); + return new Promise((resolve) => { + const subscription = servicesReady$.subscribe((isInitialized) => { + if (isInitialized) { + subscription.unsubscribe(); + resolve(); + } + }); + }); +}; + +export const setKibanaServices = ( + kibanaCore: CoreStart, + deps: NavigationEmbeddableStartDependencies +) => { + coreServices = kibanaCore; + dashboardServices = deps.dashboard; + + servicesReady$.next(true); +}; diff --git a/src/plugins/navigation_embeddable/tsconfig.json b/src/plugins/navigation_embeddable/tsconfig.json index 05ce2326f9768..a7ea3f209f7ad 100644 --- a/src/plugins/navigation_embeddable/tsconfig.json +++ b/src/plugins/navigation_embeddable/tsconfig.json @@ -1,15 +1,16 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "outDir": "target/types", + "outDir": "target/types" }, - "include": ["public/**/*", "common/**/*", "server/**/*"], + "include": ["public/**/*", "common/**/*", "server/**/*", "public/**/*.json"], "kbn_references": [ "@kbn/core", - "@kbn/embeddable-plugin", "@kbn/i18n", + "@kbn/dashboard-plugin", + "@kbn/embeddable-plugin", + "@kbn/kibana-react-plugin", + "@kbn/presentation-util-plugin", ], - "exclude": [ - "target/**/*", - ] + "exclude": ["target/**/*"] } From 92809805e8eef672ce79e463859dfc6f9b60d1e5 Mon Sep 17 00:00:00 2001 From: Andrea Del Rio Date: Fri, 14 Jul 2023 10:54:54 -0700 Subject: [PATCH 05/53] [Dashboard Navigation] Improve empty state (#161605) Closes https://github.com/elastic/kibana/issues/161552 ## Summary - Adds an illustration to the empty state in both light and dark mode. - Updates copy as suggested by Gail. ### Updated design image image ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [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 - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../public/assets/empty_links_dark.svg | 1 + .../public/assets/empty_links_light.svg | 1 + .../navigation_embeddable_panel_editor.tsx | 56 ++++++++++++------- .../navigation_embeddable_strings.ts | 2 +- 4 files changed, 38 insertions(+), 22 deletions(-) create mode 100644 src/plugins/navigation_embeddable/public/assets/empty_links_dark.svg create mode 100644 src/plugins/navigation_embeddable/public/assets/empty_links_light.svg diff --git a/src/plugins/navigation_embeddable/public/assets/empty_links_dark.svg b/src/plugins/navigation_embeddable/public/assets/empty_links_dark.svg new file mode 100644 index 0000000000000..6540d66a39419 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/assets/empty_links_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/assets/empty_links_light.svg b/src/plugins/navigation_embeddable/public/assets/empty_links_light.svg new file mode 100644 index 0000000000000..5bd8a7f8d5878 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/assets/empty_links_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx index ab24c998feab0..1c117048fcb23 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx @@ -10,7 +10,7 @@ import { isEmpty } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import React, { useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; - +import useObservable from 'react-use/lib/useObservable'; import { EuiText, EuiIcon, @@ -27,9 +27,11 @@ import { EuiButtonEmpty, EuiFlyoutFooter, EuiFlyoutHeader, + EuiImage, + EuiEmptyPrompt, } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; - +import { coreServices } from '../services/kibana_services'; import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE, @@ -40,6 +42,8 @@ import { import { NavEmbeddableStrings } from './navigation_embeddable_strings'; import { memoizedFetchDashboard } from './dashboard_link/dashboard_link_tools'; import { NavigationEmbeddableLinkEditor } from './navigation_embeddable_link_editor'; +import noLinksIllustrationDark from '../assets/empty_links_dark.svg'; +import noLinksIllustrationLight from '../assets/empty_links_light.svg'; import './navigation_embeddable.scss'; @@ -56,6 +60,7 @@ export const NavigationEmbeddablePanelEditor = ({ }) => { const [showLinkEditorFlyout, setShowLinkEditorFlyout] = useState(false); const [links, setLinks] = useState(initialInput.links); + const isDarkTheme = useObservable(coreServices.theme.theme$)?.darkMode; /** * TODO: There is probably a more efficient way of storing the dashboard information "temporarily" for any new @@ -98,25 +103,34 @@ export const NavigationEmbeddablePanelEditor = ({ <> {!links || Object.keys(links).length === 0 ? ( - - - - - {NavEmbeddableStrings.editor.panelEditor.getEmptyLinksMessage()} - - - - - - - setShowLinkEditorFlyout(true)} - iconType="plusInCircle" - > - {NavEmbeddableStrings.editor.getAddButtonLabel()} - - - + + + } + body={ + <> + + {NavEmbeddableStrings.editor.panelEditor.getEmptyLinksMessage()} + + + setShowLinkEditorFlyout(true)} + iconType="plusInCircle" + > + {NavEmbeddableStrings.editor.getAddButtonLabel()} + + + } + /> ) : ( <> diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts index 28faa11a00d23..7499eda011509 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts @@ -21,7 +21,7 @@ export const NavEmbeddableStrings = { panelEditor: { getEmptyLinksMessage: () => i18n.translate('navigationEmbeddable.panelEditor.emptyLinksMessage', { - defaultMessage: "You haven't added any links yet.", + defaultMessage: 'Use links to navigate to commonly used dashboards and websites.', }), getCreateFlyoutTitle: () => i18n.translate('navigationEmbeddable.panelEditor.createFlyoutTitle', { From c7485e683866151bcdeb4b3e9ad65625f2a66a98 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 17 Jul 2023 16:42:56 -0600 Subject: [PATCH 06/53] [Dashboard Navigation] Add link editing + reordering (#161568) Closes https://github.com/elastic/kibana/issues/154361 Closes https://github.com/elastic/kibana/issues/161274 Closes https://github.com/elastic/kibana/issues/161693 ## Summary This PR adds editing capabilities to the navigation embeddable, including deleting/editing existing links and reordering the list of links. It also fixes the delay in opening the editing flyout from the async import in `getExplicitInput` (from the navigation embeddable factory) by moving it to the constructor of the factory. https://github.com/elastic/kibana/assets/8698078/ace9dcd4-0607-40de-959e-94348a5fa4fa ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] ~[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~ Will be addressed in https://github.com/elastic/kibana/issues/161287 - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../customize_panel_action.tsx | 8 +- .../navigation_embeddable/public/_mixins.scss | 21 +- .../dashboard_link_component.tsx | 8 +- .../dashboard_link_destination_picker.tsx | 161 ++++++------ .../dashboard_link/dashboard_link_strings.ts | 4 +- .../dashboard_link/dashboard_link_tools.tsx | 99 +++++--- .../external_link_destination_picker.tsx | 27 +- .../components/navigation_embeddable.scss | 73 ++++-- .../navigation_embeddable_component.tsx | 20 +- .../navigation_embeddable_link_editor.tsx | 92 +++++-- .../navigation_embeddable_panel_editor.tsx | 235 ++++++++++-------- ...avigation_embeddable_panel_editor_link.tsx | 114 +++++++++ .../navigation_embeddable_strings.ts | 30 ++- .../navigation_embeddable_editor_tools.tsx | 34 +++ .../public/editor/open_editor_flyout.tsx | 19 +- .../public/editor/open_link_editor_flyout.tsx | 77 ++++++ .../navigation_embeddable_factory.ts | 7 +- .../public/embeddable/types.ts | 9 +- .../navigation_embeddable/tsconfig.json | 1 + 19 files changed, 757 insertions(+), 282 deletions(-) create mode 100644 src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx create mode 100644 src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx create mode 100644 src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_panel/customize_panel_action.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_panel/customize_panel_action.tsx index 6146199eee24b..5c3a3a8586354 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_panel/customize_panel_action.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_panel/customize_panel_action.tsx @@ -79,9 +79,15 @@ export class CustomizePanelAction implements Action (embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'markdown'; const isImage = embeddable.type === 'image'; + const isNavigation = embeddable.type === 'navigation'; return Boolean( - embeddable && hasTimeRange(embeddable) && !isInputControl && !isMarkdown && !isImage + embeddable && + hasTimeRange(embeddable) && + !isInputControl && + !isMarkdown && + !isImage && + !isNavigation ); } diff --git a/src/plugins/navigation_embeddable/public/_mixins.scss b/src/plugins/navigation_embeddable/public/_mixins.scss index 47249b0aa42d4..f327bc1fe73d7 100644 --- a/src/plugins/navigation_embeddable/public/_mixins.scss +++ b/src/plugins/navigation_embeddable/public/_mixins.scss @@ -1,6 +1,6 @@ @import '../../../core/public/mixins'; -@keyframes euiFlyoutAnimation { +@keyframes euiFlyoutOpenAnimation { 0% { opacity: 0; transform: translateX(100%); @@ -12,14 +12,31 @@ } } +@keyframes euiFlyoutCloseAnimation { + 0% { + opacity: 1; + transform: translateX(0%); + } + + 100% { + opacity: 0; + transform: translateX(100%); + } +} + @mixin euiFlyout { @include kibanaFullBodyHeight(); border-left: $euiBorderThin; position: fixed; - width: 50%; z-index: $euiZFlyout; background: $euiColorEmptyShade; display: flex; flex-direction: column; align-items: stretch; + inline-size: 50vw; + + @media only screen and (max-width: 767px) { + inline-size: $euiSizeXL * 13; // 424px + max-inline-size: 90vw; + } } \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx index 7c8170674d9ea..db371c426ed4d 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx @@ -29,7 +29,13 @@ export const DashboardLinkComponent = ({ link }: { link: NavigationEmbeddableLin const { loading: loadingDestinationDashboard, value: destinationDashboard } = useAsync(async () => { - return await fetchDashboard(link.destination); + if (!link.label && link.id !== parentDashboardId) { + /** + * only fetch the dashboard if **absolutely** necessary; i.e. only if the dashboard link doesn't have + * some custom label, and if it's not the current dashboard (if it is, use `dashboardContainer` instead) + */ + return await fetchDashboard(link.destination); + } }, [link, parentDashboardId]); return ( diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx index 715434dc2c80f..7156449be366d 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx @@ -8,61 +8,68 @@ import { debounce } from 'lodash'; import useAsync from 'react-use/lib/useAsync'; -import React, { useEffect, useMemo, useState } from 'react'; +import useMount from 'react-use/lib/useMount'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiBadge, - EuiSpacer, + EuiComboBox, + EuiFlexItem, EuiHighlight, - EuiSelectable, - EuiFieldSearch, - EuiSelectableOption, + EuiFlexGroup, + EuiComboBoxOptionOption, } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + import { DashboardItem } from '../../embeddable/types'; -import { memoizedFetchDashboards } from './dashboard_link_tools'; +import { memoizedFetchDashboard, memoizedFetchDashboards } from './dashboard_link_tools'; import { DashboardLinkEmbeddableStrings } from './dashboard_link_strings'; +type DashboardComboBoxOption = EuiComboBoxOptionOption; + export const DashboardLinkDestinationPicker = ({ - setDestination, - setPlaceholder, - currentDestination, + onDestinationPicked, + initialSelection, parentDashboard, ...other }: { - setDestination: (destination?: string) => void; - setPlaceholder: (placeholder?: string) => void; - currentDestination?: string; + onDestinationPicked: (selectedDashboard?: DashboardItem) => void; parentDashboard?: DashboardContainer; + initialSelection?: string; }) => { const [searchString, setSearchString] = useState(''); - const [selectedDashboard, setSelectedDashboard] = useState(); - const [dashboardListOptions, setDashboardListOptions] = useState([]); + const [selectedOption, setSelectedOption] = useState([]); const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId); - const { loading: loadingDashboardList, value: dashboardList } = useAsync(async () => { - return await memoizedFetchDashboards(searchString, undefined, parentDashboardId); - }, [searchString, parentDashboardId]); + const getDashboardItem = useCallback((dashboard: DashboardItem) => { + return { + key: dashboard.id, + value: dashboard, + label: dashboard.attributes.title, + className: 'navEmbeddableDashboardItem', + }; + }, []); - useEffect(() => { - const dashboardOptions = - (dashboardList ?? []).map((dashboard: DashboardItem) => { - return { - data: dashboard, - label: dashboard.attributes.title, - ...(dashboard.id === parentDashboardId - ? { - prepend: ( - {DashboardLinkEmbeddableStrings.getCurrentDashboardLabel()} - ), - } - : {}), - } as EuiSelectableOption; - }) ?? []; + useMount(async () => { + if (initialSelection) { + const dashboard = await memoizedFetchDashboard(initialSelection); + onDestinationPicked(dashboard); + setSelectedOption([getDashboardItem(dashboard)]); + } + }); - setDashboardListOptions(dashboardOptions); - }, [dashboardList, parentDashboardId, searchString]); + const { loading: loadingDashboardList, value: dashboardList } = useAsync(async () => { + const dashboards = await memoizedFetchDashboards({ + search: searchString, + parentDashboardId, + selectedDashboardId: initialSelection, + }); + const dashboardOptions = (dashboards ?? []).map((dashboard: DashboardItem) => { + return getDashboardItem(dashboard); + }); + return dashboardOptions; + }, [searchString, parentDashboardId, getDashboardItem]); const debouncedSetSearch = useMemo( () => @@ -72,47 +79,53 @@ export const DashboardLinkDestinationPicker = ({ [setSearchString] ); - useEffect(() => { - if (selectedDashboard) { - setDestination(selectedDashboard.id); - setPlaceholder(selectedDashboard.attributes.title); - } else { - setDestination(undefined); - setPlaceholder(undefined); - } - }, [selectedDashboard, setDestination, setPlaceholder]); + const renderOption = useCallback( + (option, searchValue, contentClassName) => { + const { label, key: dashboardId } = option; + return ( + + {dashboardId === parentDashboardId && ( + + {DashboardLinkEmbeddableStrings.getCurrentDashboardLabel()} + + )} + + + {label} + + + + ); + }, + [parentDashboardId] + ); - /* {...other} is needed so all inner elements are treated as part of the form */ + /* {...other} is needed so the EuiComboBox is treated as part of the form */ return ( -
- { - debouncedSetSearch(e.target.value); - }} - /> - - { - if (selected.checked) { - setSelectedDashboard(selected.data as DashboardItem); - } else { - setSelectedDashboard(undefined); - } - setDashboardListOptions(newOptions); - }} - renderOption={(option) => { - return {option.label}; - }} - > - {(list) => list} - -
+ { + debouncedSetSearch(searchValue); + }} + renderOption={renderOption} + selectedOptions={selectedOption} + onChange={(option) => { + setSelectedOption(option); + if (option.length > 0) { + // single select is `true`, so there is only ever one item in the array + onDestinationPicked(option[0].value); + } else { + onDestinationPicked(undefined); + } + }} + /> ); }; diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts index 9bc2e2d40f0b0..c763b0bd88e4e 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts @@ -17,8 +17,8 @@ export const DashboardLinkEmbeddableStrings = { i18n.translate('navigationEmbeddable.dsahboardLink.description', { defaultMessage: 'Go to dashboard', }), - getSearchPlaceholder: () => - i18n.translate('navigationEmbeddable.dashboardLink.editor.searchPlaceholder', { + getDashboardPickerPlaceholder: () => + i18n.translate('navigationEmbeddable.dashboardLink.editor.dashboardComboBoxPlaceholder', { defaultMessage: 'Search for a dashboard', }), getDashboardPickerAriaLabel: () => diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx index 3735e5a044ffa..9590df2bd6c0d 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx @@ -6,24 +6,16 @@ * Side Public License, v 1. */ -import { isEmpty, memoize } from 'lodash'; +import { isEmpty, memoize, filter } from 'lodash'; import { DashboardItem } from '../../embeddable/types'; import { dashboardServices } from '../../services/kibana_services'; /** - * Memoized fetch dashboard will only refetch the dashboard information if the given `dashboardId` changed between - * calls; otherwise, it will use the cached dashboard, which may not take into account changes to the dashboard's title - * description, etc. Be mindful when choosing the memoized version. + * ---------------------------------- + * Fetch a single dashboard + * ---------------------------------- */ -export const memoizedFetchDashboard = memoize( - async (dashboardId: string) => { - return await fetchDashboard(dashboardId); - }, - (dashboardId) => { - return dashboardId; - } -); export const fetchDashboard = async (dashboardId: string): Promise => { const findDashboardsService = await dashboardServices.findDashboardsService(); @@ -34,20 +26,39 @@ export const fetchDashboard = async (dashboardId: string): Promise { - return await fetchDashboards(search, size, currentDashboardId); +/** + * Memoized fetch dashboard will only refetch the dashboard information if the given `dashboardId` changed between + * calls; otherwise, it will use the cached dashboard, which may not take into account changes to the dashboard's title + * description, etc. Be mindful when choosing the memoized version. + */ +export const memoizedFetchDashboard = memoize( + async (dashboardId: string) => { + return await fetchDashboard(dashboardId); }, - (search, size, currentDashboardId) => { - return [search, size, currentDashboardId].join('|'); + (dashboardId) => { + return dashboardId; } ); -const fetchDashboards = async ( - search: string = '', - size: number = 10, - currentDashboardId?: string -): Promise => { +/** + * ---------------------------------- + * Fetch lists of dashboards + * ---------------------------------- + */ + +interface FetchDashboardsProps { + size?: number; + search?: string; + parentDashboardId?: string; + selectedDashboardId?: string; +} + +const fetchDashboards = async ({ + search = '', + size = 10, + parentDashboardId, + selectedDashboardId, +}: FetchDashboardsProps): Promise => { const findDashboardsService = await dashboardServices.findDashboardsService(); const responses = await findDashboardsService.search({ search, @@ -55,28 +66,22 @@ const fetchDashboards = async ( options: { onlyTitle: true }, }); - let currentDashboard: DashboardItem | undefined; let dashboardList: DashboardItem[] = responses.hits; - /** When the parent dashboard has been saved (i.e. it has an ID) and there is no search string ... */ - if (currentDashboardId && isEmpty(search)) { - /** ...force the current dashboard (if it is present in the original search results) to the top of the list */ - dashboardList = dashboardList.sort((dashboard) => { - const isCurrentDashboard = dashboard.id === currentDashboardId; - if (isCurrentDashboard) { - currentDashboard = dashboard; - } - return isCurrentDashboard ? -1 : 1; + /** If there is no search string... */ + if (isEmpty(search)) { + /** ... filter out both the parent and selected dashboard from the list ... */ + dashboardList = filter(dashboardList, (dash) => { + return dash.id !== parentDashboardId && dash.id !== selectedDashboardId; }); - /** - * If the current dashboard wasn't returned in the original search, perform another search to find it and - * force it to the front of the list - */ - if (!currentDashboard) { - currentDashboard = await fetchDashboard(currentDashboardId); - dashboardList.pop(); // the result should still be of `size,` so remove the dashboard at the end of the list - dashboardList.unshift(currentDashboard); // in order to force the current dashboard to the start of the list + /** ... so that we can force them to the top of the list as necessary. */ + if (parentDashboardId) { + dashboardList.unshift(await fetchDashboard(parentDashboardId)); + } + + if (selectedDashboardId && selectedDashboardId !== parentDashboardId) { + dashboardList.unshift(await fetchDashboard(selectedDashboardId)); } } @@ -87,3 +92,17 @@ const fetchDashboards = async ( return simplifiedDashboardList; }; + +export const memoizedFetchDashboards = memoize( + async ({ search, size, parentDashboardId, selectedDashboardId }: FetchDashboardsProps) => { + return await fetchDashboards({ + search, + size, + parentDashboardId, + selectedDashboardId, + }); + }, + ({ search, size, parentDashboardId, selectedDashboardId }) => { + return [search, size, parentDashboardId, selectedDashboardId].join('|'); + } +); diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx b/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx index 596ce183d696b..4119cc32f32aa 100644 --- a/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx @@ -6,7 +6,9 @@ * Side Public License, v 1. */ +import useMount from 'react-use/lib/useMount'; import React, { useState } from 'react'; + import { EuiFieldText } from '@elastic/eui'; import { ExternalLinkEmbeddableStrings } from './external_link_strings'; @@ -15,29 +17,38 @@ const isValidUrl = /^https?:\/\/(?:www.)?[-a-zA-Z0-9@:%._+~#=]{1,256}.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/; export const ExternalLinkDestinationPicker = ({ - setDestination, - setPlaceholder, - currentDestination, + onDestinationPicked, + initialSelection, ...other }: { - setDestination: (destination?: string) => void; - setPlaceholder: (placeholder?: string) => void; - currentDestination?: string; + onDestinationPicked: (destination?: string) => void; + initialSelection?: string; }) => { const [validUrl, setValidUrl] = useState(true); + const [currentUrl, setCurrentUrl] = useState(initialSelection ?? ''); + + useMount(() => { + if (initialSelection) { + onDestinationPicked(initialSelection); + setValidUrl(isValidUrl.test(initialSelection)); + } + }); /* {...other} is needed so all inner elements are treated as part of the form */ return (
{ const url = e.target.value; const isValid = isValidUrl.test(url); setValidUrl(isValid); - setDestination(isValid ? url : undefined); - setPlaceholder(isValid ? url : undefined); + setCurrentUrl(url); + if (isValid) { + onDestinationPicked(url); + } }} />
diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss b/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss index 214cbbc7a8760..e7a6e5a1890a0 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss @@ -1,23 +1,68 @@ @import '../mixins'; -.navEmbeddableLinkEditor { - @include euiFlyout; - animation: euiFlyoutAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; +.navEmbeddablePanelEditor { + max-inline-size: $euiSizeXXL * 18; // 40px * 18 = 720px + + .navEmbeddableLinkEditor { + @include euiFlyout; + max-inline-size: $euiSizeXXL * 18; + + &.in { + animation: euiFlyoutOpenAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; + } + + &.out { + animation: euiFlyoutCloseAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; + } - .linkEditorBackButton { - height: auto; + .linkEditorBackButton { + height: auto; + } } } -.navEmbeddablePanelEditor { - .linkText { - flex: 1; - min-width: 0; - - .wrapText { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.navEmbeddableDashboardItem { + .euiBadge { + cursor: pointer !important; + } + + // in order to ensure that the "Current" badge doesn't recieve an underline on hover, we have to set the + // text-decoration to `none` for the entire list item and manually set the underline **only** on the text + &:hover { + text-decoration: none; + } + + .navEmbeddableLinkText { + &:hover { + text-decoration: underline !important; + } + } +} + +.navEmbeddableLinkText { + flex: 1; + min-width: 0; + + .wrapText { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.navEmbeddableLinkPanel { + padding: $euiSizeXS $euiSizeS; + + .navEmbeddable_hoverActions { + opacity: 0; + visibility: hidden; + transition: visibility $euiAnimSpeedNormal, opacity $euiAnimSpeedNormal; + } + + &:hover, &:focus-within { + .navEmbeddable_hoverActions { + opacity: 1; + visibility: visible; } } } \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx index abcbe098f3896..b17be812accbc 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx @@ -6,35 +6,39 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiPanel } from '@elastic/eui'; import { DASHBOARD_LINK_TYPE } from '../embeddable/types'; import { useNavigationEmbeddable } from '../embeddable/navigation_embeddable'; -import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; import { ExternalLinkComponent } from './external_link/external_link_component'; +import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; +import { memoizedGetOrderedLinkList } from '../editor/navigation_embeddable_editor_tools'; export const NavigationEmbeddableComponent = () => { const navEmbeddable = useNavigationEmbeddable(); const links = navEmbeddable.select((state) => state.explicitInput.links); + const orderedLinks = useMemo(() => { + return memoizedGetOrderedLinkList(links); + }, [links]); /** TODO: Render this as a list **or** "tabs" as part of https://github.com/elastic/kibana/issues/154357 */ return ( - {Object.keys(links).map((linkId) => { + {orderedLinks.map((link) => { return ( - {links[linkId].type === DASHBOARD_LINK_TYPE ? ( - + {link.type === DASHBOARD_LINK_TYPE ? ( + ) : ( - + )} ); diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx index a0753130bcc83..1d5fa98766051 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import React, { useMemo, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiForm, @@ -33,24 +34,30 @@ import { EXTERNAL_LINK_TYPE, DASHBOARD_LINK_TYPE, NavigationEmbeddableLink, + DashboardItem, } from '../embeddable/types'; import { NavEmbeddableStrings } from './navigation_embeddable_strings'; +import { NavigationEmbeddableUnorderedLink } from '../editor/open_link_editor_flyout'; import { ExternalLinkDestinationPicker } from './external_link/external_link_destination_picker'; import { DashboardLinkDestinationPicker } from './dashboard_link/dashboard_link_destination_picker'; export const NavigationEmbeddableLinkEditor = ({ + link, onSave, onClose, parentDashboard, }: { onClose: () => void; parentDashboard?: DashboardContainer; - onSave: (newLink: NavigationEmbeddableLink) => void; + link?: NavigationEmbeddableUnorderedLink; // will only be defined if **editing** a link; otherwise, creating a new link + onSave: (newLink: Omit) => void; }) => { - const [selectedLinkType, setSelectedLinkType] = useState(DASHBOARD_LINK_TYPE); - const [linkLabel, setLinkLabel] = useState(''); - const [linkDestination, setLinkDestination] = useState(); - const [linkLabelPlaceholder, setLinkLabelPlaceholder] = useState(); + const [selectedLinkType, setSelectedLinkType] = useState( + link?.type ?? DASHBOARD_LINK_TYPE + ); + const [defaultLinkLabel, setDefaultLinkLabel] = useState(); + const [currentLinkLabel, setCurrentLinkLabel] = useState(link?.label ?? ''); + const [linkDestination, setLinkDestination] = useState(link?.destination); const linkTypes: EuiRadioGroupOption[] = useMemo(() => { return ([DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE] as NavigationLinkType[]).map((type) => { @@ -72,8 +79,35 @@ export const NavigationEmbeddableLinkEditor = ({ }); }, []); + const onDashboardSelected = useCallback( + (selectedDashboard?: DashboardItem) => { + setLinkDestination(selectedDashboard?.id); + if (selectedDashboard) { + const dashboardTitle = selectedDashboard.attributes.title; + setDefaultLinkLabel(dashboardTitle); + if (!currentLinkLabel || currentLinkLabel === defaultLinkLabel) { + setCurrentLinkLabel(dashboardTitle); + } + } + }, + [currentLinkLabel, defaultLinkLabel] + ); + + const onUrlSelected = useCallback( + (url?: string) => { + setLinkDestination(url); + if (url) { + setDefaultLinkLabel(url); + if (!currentLinkLabel || currentLinkLabel === defaultLinkLabel) { + setCurrentLinkLabel(url); + } + } + }, + [currentLinkLabel, defaultLinkLabel] + ); + return ( - + -

{NavEmbeddableStrings.editor.getAddButtonLabel()}

+

+ {link + ? NavEmbeddableStrings.editor.getEditLinkTitle() + : NavEmbeddableStrings.editor.getAddButtonLabel()} +

@@ -97,8 +135,14 @@ export const NavigationEmbeddableLinkEditor = ({ options={linkTypes} idSelected={selectedLinkType} onChange={(id) => { - setLinkDestination(undefined); - setLinkLabelPlaceholder(undefined); + if (link?.type === id) { + setLinkDestination(link.destination); + setCurrentLinkLabel(link.label ?? ''); + } else { + setLinkDestination(undefined); + setCurrentLinkLabel(''); + } + setDefaultLinkLabel(undefined); setSelectedLinkType(id as NavigationLinkType); }} /> @@ -108,15 +152,13 @@ export const NavigationEmbeddableLinkEditor = ({ {selectedLinkType === DASHBOARD_LINK_TYPE ? ( ) : ( )}
@@ -124,13 +166,11 @@ export const NavigationEmbeddableLinkEditor = ({ { - setLinkLabel(e.target.value); - }} + value={linkDestination ? currentLinkLabel : ''} + onChange={(e) => setCurrentLinkLabel(e.target.value)} /> @@ -157,15 +197,19 @@ export const NavigationEmbeddableLinkEditor = ({ // this check should always be true, since the button is disabled otherwise - this is just for type safety if (linkDestination) { onSave({ - destination: linkDestination, - label: linkLabel, + label: currentLinkLabel === defaultLinkLabel ? undefined : currentLinkLabel, type: selectedLinkType, + id: link?.id ?? uuidv4(), + destination: linkDestination, }); + onClose(); } }} > - {NavEmbeddableStrings.editor.getAddButtonLabel()} + {link + ? NavEmbeddableStrings.editor.getUpdateButtonLabel() + : NavEmbeddableStrings.editor.getAddButtonLabel()} diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx index 1c117048fcb23..f4c2ce6b51492 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx @@ -6,103 +6,164 @@ * Side Public License, v 1. */ -import { isEmpty } from 'lodash'; -import { v4 as uuidv4 } from 'uuid'; -import React, { useState } from 'react'; -import useAsync from 'react-use/lib/useAsync'; import useObservable from 'react-use/lib/useObservable'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + import { EuiText, - EuiIcon, EuiForm, + EuiImage, EuiTitle, EuiPanel, - IconType, EuiSpacer, EuiButton, + EuiToolTip, EuiFormRow, EuiFlexItem, EuiFlexGroup, + EuiDroppable, + EuiDraggable, EuiFlyoutBody, + EuiEmptyPrompt, EuiButtonEmpty, EuiFlyoutFooter, EuiFlyoutHeader, - EuiImage, - EuiEmptyPrompt, + EuiDragDropContext, + euiDragDropReorder, } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + import { coreServices } from '../services/kibana_services'; import { - DASHBOARD_LINK_TYPE, - EXTERNAL_LINK_TYPE, - NavigationEmbeddableInput, NavigationEmbeddableLink, - NavigationLinkInfo, + NavigationEmbeddableInput, + NavigationEmbeddableLinkList, } from '../embeddable/types'; import { NavEmbeddableStrings } from './navigation_embeddable_strings'; -import { memoizedFetchDashboard } from './dashboard_link/dashboard_link_tools'; -import { NavigationEmbeddableLinkEditor } from './navigation_embeddable_link_editor'; + +import { openLinkEditorFlyout } from '../editor/open_link_editor_flyout'; +import { memoizedGetOrderedLinkList } from '../editor/navigation_embeddable_editor_tools'; +import { NavigationEmbeddablePanelEditorLink } from './navigation_embeddable_panel_editor_link'; + import noLinksIllustrationDark from '../assets/empty_links_dark.svg'; import noLinksIllustrationLight from '../assets/empty_links_light.svg'; import './navigation_embeddable.scss'; -export const NavigationEmbeddablePanelEditor = ({ +const NavigationEmbeddablePanelEditor = ({ onSave, onClose, initialInput, parentDashboard, }: { onClose: () => void; + parentDashboard?: DashboardContainer; initialInput: Partial; onSave: (input: Partial) => void; - parentDashboard?: DashboardContainer; }) => { - const [showLinkEditorFlyout, setShowLinkEditorFlyout] = useState(false); - const [links, setLinks] = useState(initialInput.links); const isDarkTheme = useObservable(coreServices.theme.theme$)?.darkMode; + const editLinkFlyoutRef: React.RefObject = useMemo(() => React.createRef(), []); + + const [orderedLinks, setOrderedLinks] = useState([]); + + useEffect(() => { + const { links: initialLinks } = initialInput; + if (!initialLinks) { + setOrderedLinks([]); + return; + } + setOrderedLinks(memoizedGetOrderedLinkList(initialLinks)); + }, [initialInput]); - /** - * TODO: There is probably a more efficient way of storing the dashboard information "temporarily" for any new - * panels and only fetching the dashboard saved objects when first loading this flyout. - * - * Will need to think this through and fix as part of the editing process - not worth holding this PR, since it's - * blocking so much other work :) - */ - const { value: linkList } = useAsync(async () => { - if (!links || isEmpty(links)) return []; - - const newLinks: Array<{ id: string; icon: IconType; label: string }> = await Promise.all( - Object.keys(links).map(async (panelId) => { - let label = links[panelId].label; - let icon = NavigationLinkInfo[EXTERNAL_LINK_TYPE].icon; - - if (links[panelId].type === DASHBOARD_LINK_TYPE) { - icon = NavigationLinkInfo[DASHBOARD_LINK_TYPE].icon; - if (!label) { - const dashboard = await memoizedFetchDashboard(links[panelId].destination); - label = dashboard.attributes.title; - } + const onDragEnd = useCallback( + ({ source, destination }) => { + if (source && destination) { + const newList = euiDragDropReorder(orderedLinks, source.index, destination.index); + setOrderedLinks(newList); + } + }, + [orderedLinks] + ); + + const addOrEditLink = useCallback( + async (linkToEdit?: NavigationEmbeddableLink) => { + const newLink = await openLinkEditorFlyout({ + parentDashboard, + link: linkToEdit, + ref: editLinkFlyoutRef, + }); + if (newLink) { + if (linkToEdit) { + setOrderedLinks( + orderedLinks.map((link) => { + if (link.id === linkToEdit.id) { + return { ...newLink, order: linkToEdit.order }; + } + return link; + }) + ); + } else { + setOrderedLinks([...orderedLinks, { ...newLink, order: orderedLinks.length }]); } + } + }, + [editLinkFlyoutRef, orderedLinks, parentDashboard] + ); - return { id: panelId, label: label || links[panelId].destination, icon }; - }) + const deleteLink = useCallback( + (linkId: string) => { + setOrderedLinks( + orderedLinks.filter((link) => { + return link.id !== linkId; + }) + ); + }, + [orderedLinks] + ); + + const saveButtonComponent = useMemo(() => { + const canSave = orderedLinks.length !== 0; + + const button = ( + { + const newLinks = orderedLinks.reduce((prev, link, i) => { + return { ...prev, [link.id]: { ...link, order: i } }; + }, {} as NavigationEmbeddableLinkList); + onSave({ links: newLinks }); + }} + > + {NavEmbeddableStrings.editor.panelEditor.getSaveButtonLabel()} + ); - return newLinks; - }, [links]); + + return canSave ? ( + button + ) : ( + + {button} + + ); + }, [onSave, orderedLinks]); return ( <> +
-

{NavEmbeddableStrings.editor.panelEditor.getCreateFlyoutTitle()}

+

+ {initialInput.links && Object.keys(initialInput.links).length > 0 + ? NavEmbeddableStrings.editor.panelEditor.getEditFlyoutTitle() + : NavEmbeddableStrings.editor.panelEditor.getCreateFlyoutTitle()} +

<> - {!links || Object.keys(links).length === 0 ? ( + {orderedLinks.length === 0 ? ( - setShowLinkEditorFlyout(true)} - iconType="plusInCircle" - > + addOrEditLink()} iconType="plusInCircle"> {NavEmbeddableStrings.editor.getAddButtonLabel()} @@ -134,33 +191,31 @@ export const NavigationEmbeddablePanelEditor = ({ ) : ( <> - {linkList?.map((link) => { - return ( -
- + + {orderedLinks.map((link, idx) => ( + - - - - - -
{link.label}
-
-
-
- -
- ); - })} - setShowLinkEditorFlyout(true)} - > + {(provided) => ( + addOrEditLink(link)} + deleteLink={() => deleteLink(link.id)} + dragHandleProps={provided.dragHandleProps} + /> + )} + + ))} + + + addOrEditLink()}> {NavEmbeddableStrings.editor.getAddButtonLabel()} @@ -176,31 +231,13 @@ export const NavigationEmbeddablePanelEditor = ({ {NavEmbeddableStrings.editor.getCancelButtonLabel()} - - { - onSave({ ...initialInput, links }); - onClose(); - }} - > - {NavEmbeddableStrings.editor.panelEditor.getSaveButtonLabel()} - - + {saveButtonComponent} - - {showLinkEditorFlyout && ( - { - setShowLinkEditorFlyout(false); - }} - onSave={(newLink: NavigationEmbeddableLink) => { - setLinks({ ...links, [uuidv4()]: newLink }); - }} - parentDashboard={parentDashboard} - /> - )} ); }; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default NavigationEmbeddablePanelEditor; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx new file mode 100644 index 0000000000000..be65c130222eb --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { + EuiIcon, + EuiPanel, + EuiFlexItem, + EuiFlexGroup, + EuiButtonIcon, + EuiSkeletonTitle, + DraggableProvidedDragHandleProps, + EuiToolTip, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { + NavigationLinkInfo, + DASHBOARD_LINK_TYPE, + NavigationEmbeddableLink, +} from '../embeddable/types'; +import { fetchDashboard } from './dashboard_link/dashboard_link_tools'; +import { NavEmbeddableStrings } from './navigation_embeddable_strings'; + +export const NavigationEmbeddablePanelEditorLink = ({ + link, + editLink, + deleteLink, + parentDashboard, + dragHandleProps, +}: { + editLink: () => void; + deleteLink: () => void; + link: NavigationEmbeddableLink; + parentDashboard?: DashboardContainer; + dragHandleProps?: DraggableProvidedDragHandleProps; +}) => { + const parentDashboardTitle = parentDashboard?.select((state) => state.explicitInput.title); + const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId); + + const { value: linkLabel, loading: linkLabelLoading } = useAsync(async () => { + let label = link.label; + if (link.type === DASHBOARD_LINK_TYPE && !label) { + if (parentDashboardId === link.destination) { + label = parentDashboardTitle; + } else { + const dashboard = await fetchDashboard(link.destination); + label = dashboard.attributes.title; + } + } + return label || link.destination; + }, [link]); + + return ( + + + + + + + + + + + + +
{linkLabel}
+
+
+ + + + + + + + + + + + + + +
+
+ ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts index 7499eda011509..5628c3444d2d4 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts @@ -14,6 +14,18 @@ export const NavEmbeddableStrings = { i18n.translate('navigationEmbeddable.editor.addButtonLabel', { defaultMessage: 'Add link', }), + getUpdateButtonLabel: () => + i18n.translate('navigationEmbeddable.editor.updateButtonLabel', { + defaultMessage: 'Update link', + }), + getEditLinkTitle: () => + i18n.translate('navigationEmbeddable.editor.editLinkTitle', { + defaultMessage: 'Edit link', + }), + getDeleteLinkTitle: () => + i18n.translate('navigationEmbeddable.editor.deleteLinkTitle', { + defaultMessage: 'Delete link', + }), getCancelButtonLabel: () => i18n.translate('navigationEmbeddable.editor.cancelButtonLabel', { defaultMessage: 'Close', @@ -23,14 +35,30 @@ export const NavEmbeddableStrings = { i18n.translate('navigationEmbeddable.panelEditor.emptyLinksMessage', { defaultMessage: 'Use links to navigate to commonly used dashboards and websites.', }), + getEmptyLinksTooltip: () => + i18n.translate('navigationEmbeddable.panelEditor.emptyLinksTooltip', { + defaultMessage: 'Add one or more links.', + }), getCreateFlyoutTitle: () => i18n.translate('navigationEmbeddable.panelEditor.createFlyoutTitle', { defaultMessage: 'Create links panel', }), + getEditFlyoutTitle: () => + i18n.translate('navigationEmbeddable.panelEditor.editFlyoutTitle', { + defaultMessage: 'Edit links panel', + }), getSaveButtonLabel: () => i18n.translate('navigationEmbeddable.panelEditor.saveButtonLabel', { defaultMessage: 'Save', }), + getLinkLoadingAriaLabel: () => + i18n.translate('navigationEmbeddable.linkEditor.linkLoadingAriaLabel', { + defaultMessage: 'Loading link', + }), + getDragHandleAriaLabel: () => + i18n.translate('navigationEmbeddable.editor.dragHandleAriaLabel', { + defaultMessage: 'Link drag handle', + }), }, linkEditor: { getGoBackAriaLabel: () => @@ -47,7 +75,7 @@ export const NavEmbeddableStrings = { }), getLinkTextLabel: () => i18n.translate('navigationEmbeddable.linkEditor.linkTextLabel', { - defaultMessage: 'Text (optional)', + defaultMessage: 'Text', }), getLinkTextPlaceholder: () => i18n.translate('navigationEmbeddable.linkEditor.linkTextPlaceholder', { diff --git a/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx b/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx new file mode 100644 index 0000000000000..83cc4cfdc7c40 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { memoize } from 'lodash'; +import { NavigationEmbeddableLink, NavigationEmbeddableLinkList } from '../embeddable/types'; + +const getOrderedLinkList = (links: NavigationEmbeddableLinkList): NavigationEmbeddableLink[] => { + return Object.keys(links) + .map((linkId) => { + return links[linkId]; + }) + .sort((linkA, linkB) => { + return linkA.order - linkB.order; + }); +}; + +/** + * Memoizing this prevents the navigation embeddable panel editor from having to unnecessarily calculate this + * a second time once the embeddable exists - after all, the navigation embeddable component should have already + * calculated this so, we can get away with using the cached version in the editor + */ +export const memoizedGetOrderedLinkList = memoize( + (links: NavigationEmbeddableLinkList) => { + return getOrderedLinkList(links); + }, + (links) => { + return links; + } +); diff --git a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx index e47ed639f501d..19edb5fb4f2c8 100644 --- a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx +++ b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx @@ -8,14 +8,28 @@ import React from 'react'; import { Subject } from 'rxjs'; +import { memoize } from 'lodash'; import { skip, take, takeUntil } from 'rxjs/operators'; +import { withSuspense } from '@kbn/shared-ux-utility'; +import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { coreServices } from '../services/kibana_services'; import { NavigationEmbeddableInput } from '../embeddable/types'; -import { NavigationEmbeddablePanelEditor } from '../components/navigation_embeddable_panel_editor'; +import { memoizedFetchDashboards } from '../components/dashboard_link/dashboard_link_tools'; + +const LazyNavigationEmbeddablePanelEditor = React.lazy( + () => import('../components/navigation_embeddable_panel_editor') +); + +const NavigationEmbeddablePanelEditor = withSuspense( + LazyNavigationEmbeddablePanelEditor, + + + +); /** * @throws in case user cancels @@ -60,10 +74,13 @@ export async function openEditorFlyout( ownFocus: true, outsideClickCloses: false, onClose: onCancel, + className: 'navEmbeddablePanelEditor', } ); editorFlyout.onClose.then(() => { + // we should always re-fetch the dashboards when the editor is opened; so, clear the cache on close + memoizedFetchDashboards.cache = new memoize.Cache(); closed$.next(true); }); }); diff --git a/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx new file mode 100644 index 0000000000000..794b8812d793b --- /dev/null +++ b/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { coreServices } from '../services/kibana_services'; +import { NavigationEmbeddableLink } from '../embeddable/types'; +import { NavigationEmbeddableLinkEditor } from '../components/navigation_embeddable_link_editor'; + +export interface LinkEditorProps { + link?: NavigationEmbeddableLink; + parentDashboard?: DashboardContainer; + ref: React.RefObject; +} + +/** + * This editor has no context about other links, so it cannot determine order; order will be determined + * by the **caller** (i.e. the panel editor, which contains the context about **all links**) + */ +export type NavigationEmbeddableUnorderedLink = Omit; + +/** + * @throws in case user cancels + */ +export async function openLinkEditorFlyout({ + ref, + link, + parentDashboard, +}: LinkEditorProps): Promise { + const unmountFlyout = async () => { + if (ref.current) { + ref.current.children[1].className = 'navEmbeddableLinkEditor out'; + } + await new Promise(() => { + // wait for close animation before unmounting + setTimeout(() => { + if (ref.current) ReactDOM.unmountComponentAtNode(ref.current); + }, 180); + }); + }; + + return new Promise((resolve, reject) => { + const onSave = async (newLink: NavigationEmbeddableUnorderedLink) => { + resolve(newLink); + await unmountFlyout(); + }; + + const onCancel = async () => { + reject(); + await unmountFlyout(); + }; + + ReactDOM.render( + + + , + ref.current + ); + }).catch(() => { + // on reject (i.e. on cancel), just return the original list of links + return undefined; + }); +} diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts index 8f9985b687665..8a9662492909a 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts @@ -33,7 +33,6 @@ export class NavigationEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { public readonly type = NAVIGATION_EMBEDDABLE_TYPE; - public isContainerType = false; public async isEditable() { @@ -75,11 +74,9 @@ export class NavigationEmbeddableFactoryDefinition ) { if (!parent) return {}; - const { openEditorFlyout: createNavigationEmbeddable } = await import( - '../editor/open_editor_flyout' - ); + const { openEditorFlyout } = await import('../editor/open_editor_flyout'); - const input = await createNavigationEmbeddable( + const input = await openEditorFlyout( { ...getDefaultNavigationEmbeddableInput(), ...initialInput }, parent ).catch(() => { diff --git a/src/plugins/navigation_embeddable/public/embeddable/types.ts b/src/plugins/navigation_embeddable/public/embeddable/types.ts index 40dd5901db948..0513d50fc8cb8 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/types.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/types.ts @@ -33,14 +33,19 @@ export const EXTERNAL_LINK_TYPE = 'externalLink'; export type NavigationLinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; export interface NavigationEmbeddableLink { + id: string; type: NavigationLinkType; destination: string; - // order: number; TODO: Use this as part of https://github.com/elastic/kibana/issues/154361 label?: string; + order: number; +} + +export interface NavigationEmbeddableLinkList { + [id: string]: NavigationEmbeddableLink; } export interface NavigationEmbeddableInput extends EmbeddableInput { - links: { [id: string]: NavigationEmbeddableLink }; + links: NavigationEmbeddableLinkList; } export const NavigationLinkInfo: { diff --git a/src/plugins/navigation_embeddable/tsconfig.json b/src/plugins/navigation_embeddable/tsconfig.json index a7ea3f209f7ad..3c1cee2edb3d7 100644 --- a/src/plugins/navigation_embeddable/tsconfig.json +++ b/src/plugins/navigation_embeddable/tsconfig.json @@ -11,6 +11,7 @@ "@kbn/embeddable-plugin", "@kbn/kibana-react-plugin", "@kbn/presentation-util-plugin", + "@kbn/shared-ux-utility", ], "exclude": ["target/**/*"] } From 577134c2013a2f1caa5ea4b616f36dabd5d22e37 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 10 Aug 2023 13:42:10 -0400 Subject: [PATCH 07/53] [Navigation embeddable] Add content management (#160896) Fixes https://github.com/elastic/kibana/issues/154362 ## Summary Adds content management to navigation embeddable feature branch. Allows Links panels to be by-value or by-reference on a Dashboard. The UX for users to choose to save by-value or by-reference remains to be finalized and is out of scope for this PR. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Hannah Mudge --- .../src/constants.ts | 1 + .../src/kibana_migrator_utils.fixtures.ts | 17 +++ .../current_mappings.json | 17 +++ .../group2/check_registered_types.test.ts | 1 + .../group5/dot_kibana_split.test.ts | 1 + .../replace_panel_flyout.tsx | 30 +--- .../embeddable/api/panel_management.ts | 49 ++----- .../public/lib/actions/edit_panel_action.ts | 8 +- .../public/lib/containers/container.ts | 38 ++++-- .../public/lib/containers/i_container.ts | 5 +- .../navigation_embeddable/common/constants.ts | 19 +++ .../common/content_management/cm_services.ts | 21 +++ .../common/content_management/index.ts | 23 ++++ .../common/content_management/latest.ts | 9 ++ .../content_management/v1/cm_services.ts | 108 +++++++++++++++ .../common/content_management/v1/constants.ts | 17 +++ .../common/content_management/v1/index.ts | 17 +++ .../common/content_management/v1/types.ts | 46 +++++++ .../navigation_embeddable/common/index.ts | 9 ++ .../navigation_embeddable/common/types.ts | 19 +++ .../navigation_embeddable/kibana.jsonc | 17 ++- .../dashboard_link_component.tsx | 7 +- .../external_link/external_link_component.tsx | 7 +- .../navigation_embeddable_component.tsx | 9 +- .../navigation_embeddable_link_editor.tsx | 8 +- .../navigation_embeddable_panel_editor.tsx | 129 ++++++++++++------ ...avigation_embeddable_panel_editor_link.tsx | 7 +- .../navigation_embeddable_strings.ts | 39 +++++- .../public/components/tooltip_wrapper.tsx | 35 +++++ .../duplicate_title_check.ts | 57 ++++++++ .../public/content_management/index.ts | 10 ++ ...embeddable_content_management_client.ts.ts | 83 +++++++++++ .../content_management/save_to_library.tsx | 82 +++++++++++ .../navigation_embeddable_editor_tools.tsx | 18 +-- .../public/editor/open_editor_flyout.tsx | 48 ++++++- .../public/editor/open_link_editor_flyout.tsx | 2 +- .../public/embeddable/index.ts | 5 +- .../embeddable/navigation_embeddable.tsx | 90 ++++++++++-- .../navigation_embeddable_factory.ts | 80 +++++++---- .../public/embeddable/types.ts | 60 ++++---- .../navigation_embeddable/public/index.ts | 6 +- .../navigation_embeddable/public/plugin.ts | 27 +++- .../public/services/attribute_service.ts | 93 +++++++++++++ .../public/services/kibana_services.ts | 6 + .../server/content_management/index.ts | 9 ++ .../navigation_embeddable_storage.ts | 23 ++++ .../navigation_embeddable/server/index.ts | 11 ++ .../navigation_embeddable/server/plugin.ts | 43 ++++++ .../server/saved_objects/index.ts | 9 ++ .../saved_objects/navigation_embeddable.ts | 40 ++++++ .../navigation_embeddable/tsconfig.json | 8 ++ 51 files changed, 1275 insertions(+), 248 deletions(-) create mode 100644 src/plugins/navigation_embeddable/common/constants.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/cm_services.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/index.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/latest.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/constants.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/index.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/types.ts create mode 100644 src/plugins/navigation_embeddable/common/index.ts create mode 100644 src/plugins/navigation_embeddable/common/types.ts create mode 100644 src/plugins/navigation_embeddable/public/components/tooltip_wrapper.tsx create mode 100644 src/plugins/navigation_embeddable/public/content_management/duplicate_title_check.ts create mode 100644 src/plugins/navigation_embeddable/public/content_management/index.ts create mode 100644 src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts create mode 100644 src/plugins/navigation_embeddable/public/content_management/save_to_library.tsx create mode 100644 src/plugins/navigation_embeddable/public/services/attribute_service.ts create mode 100644 src/plugins/navigation_embeddable/server/content_management/index.ts create mode 100644 src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts create mode 100644 src/plugins/navigation_embeddable/server/index.ts create mode 100644 src/plugins/navigation_embeddable/server/plugin.ts create mode 100644 src/plugins/navigation_embeddable/server/saved_objects/index.ts create mode 100644 src/plugins/navigation_embeddable/server/saved_objects/navigation_embeddable.ts diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts index c4a3018fdfb37..9347ce4fd7f1b 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts @@ -77,6 +77,7 @@ export const DEFAULT_INDEX_TYPES_MAP: IndexTypesMap = { 'ml-module', 'ml-trained-model', 'monitoring-telemetry', + 'navigation_embeddable', 'osquery-manager-usage-metric', 'osquery-pack', 'osquery-pack-asset', diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts index 9100f489bef42..94780b9abf808 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts @@ -1504,6 +1504,23 @@ export const INDEX_MAP_BEFORE_SPLIT: IndexMap = { }, }, }, + navigation_embeddable: { + properties: { + id: { + type: 'text', + }, + title: { + type: 'text', + }, + description: { + type: 'text', + }, + links: { + dynamic: false, + properties: {}, + }, + }, + }, 'cases-comments': { dynamic: false, properties: { diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index cb78e060d6edc..169221d659e53 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1054,6 +1054,23 @@ } } }, + "navigation_embeddable": { + "properties": { + "id": { + "type": "text" + }, + "title": { + "type": "text" + }, + "description": { + "type": "text" + }, + "links": { + "dynamic": false, + "properties": {} + } + } + }, "lens": { "properties": { "title": { diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index c1cfca07017bc..bc6be0a11b2b5 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -120,6 +120,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ml-module": "2225cbb4bd508ea5f69db4b848be9d8a74b60198", "ml-trained-model": "482195cefd6b04920e539d34d7356d22cb68e4f3", "monitoring-telemetry": "5d91bf75787d9d4dd2fae954d0b3f76d33d2e559", + "navigation_embeddable": "de71a127ed325261ca6bc926d93c4cd676d17a05", "observability-onboarding-state": "55b112d6a33fedb7c1e4fec4da768d2bcc5fadc2", "osquery-manager-usage-metric": "983bcbc3b7dda0aad29b20907db233abba709bcc", "osquery-pack": "6ab4358ca4304a12dcfc1777c8135b75cffb4397", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index 30888521d651e..99b16aec71079 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -240,6 +240,7 @@ describe('split .kibana index into multiple system indices', () => { "ml-module", "ml-trained-model", "monitoring-telemetry", + "navigation_embeddable", "observability-onboarding-state", "osquery-manager-usage-metric", "osquery-pack", diff --git a/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx index 14067f0b6aa68..6f93b08a2708f 100644 --- a/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx @@ -18,7 +18,6 @@ import { } from '@kbn/embeddable-plugin/public'; import { Toast } from '@kbn/core/public'; -import { DashboardPanelState } from '../../common'; import { pluginServices } from '../services/plugin_services'; import { dashboardReplacePanelActionStrings } from './_dashboard_actions_strings'; import { DashboardContainer } from '../dashboard_container'; @@ -58,30 +57,15 @@ export class ReplacePanelFlyout extends React.Component { public onReplacePanel = async (savedObjectId: string, type: string, name: string) => { const { panelToRemove, container } = this.props; - const { w, h, x, y } = (container.getInput().panels[panelToRemove.id] as DashboardPanelState) - .gridData; - const { id } = await container.addNewEmbeddable(type, { - savedObjectId, - }); - - const { [panelToRemove.id]: omit, ...panels } = container.getInput().panels; - - container.updateInput({ - panels: { - ...panels, - [id]: { - ...panels[id], - gridData: { - ...(panels[id] as DashboardPanelState).gridData, - w, - h, - x, - y, - }, - } as DashboardPanelState, + const id = await container.replaceEmbeddable( + panelToRemove.id, + { + savedObjectId, }, - }); + type, + true + ); (container as DashboardContainer).setHighlightPanelId(id); this.showToast(name); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts index 7b02001a93c6c..a052a8fef1031 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts @@ -47,46 +47,15 @@ export async function replacePanel( newPanelState: Partial, generateNewId?: boolean ): Promise { - let panels; - let panelId; - - if (generateNewId) { - // replace panel can be called with generateNewId in order to totally destroy and recreate the embeddable - panelId = uuidv4(); - panels = { ...this.input.panels }; - delete panels[previousPanelState.explicitInput.id]; - panels[panelId] = { - ...previousPanelState, - ...newPanelState, - gridData: { - ...previousPanelState.gridData, - i: panelId, - }, - explicitInput: { - ...newPanelState.explicitInput, - id: panelId, - }, - }; - } else { - // Because the embeddable type can change, we have to operate at the container level here - panelId = previousPanelState.explicitInput.id; - panels = { - ...this.input.panels, - [panelId]: { - ...previousPanelState, - ...newPanelState, - gridData: { - ...previousPanelState.gridData, - }, - explicitInput: { - ...newPanelState.explicitInput, - id: panelId, - }, - }, - }; - } - - await this.updateInput({ panels }); + const panelId = await this.replaceEmbeddable( + previousPanelState.explicitInput.id, + { + ...newPanelState.explicitInput, + id: previousPanelState.explicitInput.id, + }, + newPanelState.type, + generateNewId + ); return panelId; } diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index 98e541fd08e6f..2076c8d6f1e7f 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -92,7 +92,13 @@ export class EditPanelAction implements Action { } const oldExplicitInput = embeddable.getExplicitInput(); - const newExplicitInput = await factory.getExplicitInput(oldExplicitInput, embeddable.parent); + let newExplicitInput: Awaited>; + try { + newExplicitInput = await factory.getExplicitInput(oldExplicitInput, embeddable.parent); + } catch (e) { + // error likely means user canceled editing + return; + } embeddable.parent?.replaceEmbeddable(embeddable.id, newExplicitInput); return; } diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 2f29410bc3e59..df007a93483e4 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -176,7 +176,12 @@ export abstract class Container< EEI extends EmbeddableInput = EmbeddableInput, EEO extends EmbeddableOutput = EmbeddableOutput, E extends IEmbeddable = IEmbeddable - >(id: string, newExplicitInput: Partial, newType?: string) { + >( + id: string, + newExplicitInput: Partial, + newType?: string, + generateNewId?: boolean + ): Promise { if (!this.input.panels[id]) { throw new PanelNotFoundError(); } @@ -186,21 +191,28 @@ export abstract class Container< if (!factory) { throw new EmbeddableFactoryNotFoundError(newType); } - this.updateInput({ - panels: { - ...this.input.panels, - [id]: { - ...this.input.panels[id], - explicitInput: { ...newExplicitInput, id }, - type: newType, - }, - }, - } as Partial); - } else { - this.updateInputForChild(id, newExplicitInput); } + const panels = { ...this.input.panels }; + const oldPanel = panels[id]; + + if (generateNewId) { + delete panels[id]; + id = uuidv4(); + } + this.updateInput({ + panels: { + ...panels, + [id]: { + ...oldPanel, + explicitInput: { ...newExplicitInput, id }, + type: newType ?? oldPanel.type, + }, + }, + } as Partial); + await this.untilEmbeddableLoaded(id); + return id; } public removeEmbeddable(embeddableId: string) { diff --git a/src/plugins/embeddable/public/lib/containers/i_container.ts b/src/plugins/embeddable/public/lib/containers/i_container.ts index 5539f854b24d9..34e7cc0593e64 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -106,6 +106,7 @@ export interface IContainer< >( id: string, newExplicitInput: Partial, - newType?: string - ): void; + newType?: string, + generateNewId?: boolean + ): Promise; } diff --git a/src/plugins/navigation_embeddable/common/constants.ts b/src/plugins/navigation_embeddable/common/constants.ts new file mode 100644 index 0000000000000..9731275e04f14 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/constants.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const LATEST_VERSION = 1; + +export const CONTENT_ID = 'navigation_embeddable'; + +export const APP_ICON = 'link'; + +export const APP_NAME = i18n.translate('navigationEmbeddable.visTypeAlias.title', { + defaultMessage: 'Links', +}); diff --git a/src/plugins/navigation_embeddable/common/content_management/cm_services.ts b/src/plugins/navigation_embeddable/common/content_management/cm_services.ts new file mode 100644 index 0000000000000..fa050138b35ff --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/cm_services.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ContentManagementServicesDefinition as ServicesDefinition, + Version, +} from '@kbn/object-versioning'; + +// We export the versioned service definition from this file and not the barrel to avoid adding +// the schemas in the "public" js bundle + +import { serviceDefinition as v1 } from './v1/cm_services'; + +export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = { + 1: v1, +}; diff --git a/src/plugins/navigation_embeddable/common/content_management/index.ts b/src/plugins/navigation_embeddable/common/content_management/index.ts new file mode 100644 index 0000000000000..282ba879c17fc --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { LATEST_VERSION, CONTENT_ID } from '../constants'; + +export type { NavigationEmbeddableContentType } from '../types'; + +export type { + NavigationEmbeddableCrudTypes, + NavigationEmbeddableAttributes, + NavigationEmbeddableItem, + NavigationLinkType, + NavigationEmbeddableLink, +} from './latest'; + +export { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from './latest'; + +export * as NavigationEmbeddableV1 from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/latest.ts b/src/plugins/navigation_embeddable/common/content_management/latest.ts new file mode 100644 index 0000000000000..e9c79f0f50f93 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/latest.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts new file mode 100644 index 0000000000000..5494a193ba7b5 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning'; +import { + savedObjectSchema, + objectTypeToGetResultSchema, + createOptionsSchemas, + updateOptionsSchema, + createResultSchema, +} from '@kbn/content-management-utils'; +import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '.'; + +const navigationEmbeddableLinkSchema = schema.object({ + id: schema.string(), + type: schema.oneOf([schema.literal(DASHBOARD_LINK_TYPE), schema.literal(EXTERNAL_LINK_TYPE)]), + destination: schema.string(), + label: schema.maybe(schema.string()), + order: schema.number(), +}); + +const navigationEmbeddableAttributesSchema = schema.object( + { + title: schema.string(), + description: schema.maybe(schema.string()), + links: schema.maybe(schema.arrayOf(navigationEmbeddableLinkSchema)), + }, + { unknowns: 'forbid' } +); + +const navigationEmbeddableSavedObjectSchema = savedObjectSchema( + navigationEmbeddableAttributesSchema +); + +const searchOptionsSchema = schema.maybe( + schema.object( + { + onlyTitle: schema.maybe(schema.boolean()), + }, + { unknowns: 'forbid' } + ) +); + +const navigationEmbeddableCreateOptionsSchema = schema.object({ + references: schema.maybe(createOptionsSchemas.references), + overwrite: createOptionsSchemas.overwrite, +}); + +const navigationEmbeddableUpdateOptionsSchema = schema.object({ + references: updateOptionsSchema.references, +}); + +// Content management service definition. +// We need it for BWC support between different versions of the content +export const serviceDefinition: ServicesDefinition = { + get: { + out: { + result: { + schema: objectTypeToGetResultSchema(navigationEmbeddableSavedObjectSchema), + }, + }, + }, + create: { + in: { + options: { + schema: navigationEmbeddableCreateOptionsSchema, + }, + data: { + schema: navigationEmbeddableAttributesSchema, + }, + }, + out: { + result: { + schema: createResultSchema(navigationEmbeddableSavedObjectSchema), + }, + }, + }, + update: { + in: { + options: { + schema: navigationEmbeddableUpdateOptionsSchema, // same schema as "create" + }, + data: { + schema: navigationEmbeddableAttributesSchema, + }, + }, + }, + search: { + in: { + options: { + schema: searchOptionsSchema, + }, + }, + }, + mSearch: { + out: { + result: { + schema: navigationEmbeddableSavedObjectSchema, + }, + }, + }, +}; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts b/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts new file mode 100644 index 0000000000000..00f40932638fe --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Dashboard to dashboard links + */ +export const DASHBOARD_LINK_TYPE = 'dashboardLink'; + +/** + * External URL links + */ +export const EXTERNAL_LINK_TYPE = 'externalLink'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts new file mode 100644 index 0000000000000..bedc5a6ff2f08 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { NavigationEmbeddableCrudTypes } from './types'; +export type { + NavigationEmbeddableCrudTypes, + NavigationEmbeddableAttributes, + NavigationEmbeddableLink, + NavigationLinkType, +} from './types'; +export type NavigationEmbeddableItem = NavigationEmbeddableCrudTypes['Item']; +export { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from './constants'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts new file mode 100644 index 0000000000000..0d1a87a17d148 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ContentManagementCrudTypes, + SavedObjectCreateOptions, + SavedObjectUpdateOptions, +} from '@kbn/content-management-utils'; +import { NavigationEmbeddableContentType } from '../../types'; +import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from './constants'; + +export type NavigationEmbeddableCrudTypes = ContentManagementCrudTypes< + NavigationEmbeddableContentType, + NavigationEmbeddableAttributes, + Pick, + Pick, + { + /** Flag to indicate to only search the text on the "title" field */ + onlyTitle?: boolean; + } +>; + +/** + * Navigation embeddable explicit input + */ +export type NavigationLinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; + +export interface NavigationEmbeddableLink { + id: string; + type: NavigationLinkType; + destination: string; + label?: string; + order: number; +} + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type NavigationEmbeddableAttributes = { + title: string; + description?: string; + links?: NavigationEmbeddableLink[]; +}; diff --git a/src/plugins/navigation_embeddable/common/index.ts b/src/plugins/navigation_embeddable/common/index.ts new file mode 100644 index 0000000000000..9cb4fc42124aa --- /dev/null +++ b/src/plugins/navigation_embeddable/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from './constants'; diff --git a/src/plugins/navigation_embeddable/common/types.ts b/src/plugins/navigation_embeddable/common/types.ts new file mode 100644 index 0000000000000..e03b4a4dd1469 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectsResolveResponse } from '@kbn/core-saved-objects-api-server'; + +export type NavigationEmbeddableContentType = 'navigation_embeddable'; + +// TODO does this type need to be versioned? +export interface SharingSavedObjectProps { + outcome: SavedObjectsResolveResponse['outcome']; + aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; + sourceId?: string; +} diff --git a/src/plugins/navigation_embeddable/kibana.jsonc b/src/plugins/navigation_embeddable/kibana.jsonc index 961aacc7641aa..b74e4bbd6f330 100644 --- a/src/plugins/navigation_embeddable/kibana.jsonc +++ b/src/plugins/navigation_embeddable/kibana.jsonc @@ -1,14 +1,23 @@ { "type": "plugin", - "owner": "@elastic/kibana-presentation", "id": "@kbn/navigation-embeddable-plugin", + "owner": "@elastic/kibana-presentation", "description": "An embeddable for quickly navigating between dashboards.", "plugin": { "id": "navigationEmbeddable", - "server": false, + "server": true, "browser": true, - "requiredPlugins": ["dashboard", "embeddable", "kibanaReact", "presentationUtil"], + "requiredPlugins": [ + "contentManagement", + "dashboard", + "embeddable", + "kibanaReact", + "presentationUtil" + ], "optionalPlugins": ["triggersActionsUi"], - "requiredBundles": [] + "requiredBundles": [ + "savedObjects" + ] } } + diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx index db371c426ed4d..60b88a740c14d 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx @@ -12,13 +12,10 @@ import useAsync from 'react-use/lib/useAsync'; import { EuiButtonEmpty } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { - DASHBOARD_LINK_TYPE, - NavigationEmbeddableLink, - NavigationLinkInfo, -} from '../../embeddable/types'; +import { NavigationLinkInfo } from '../../embeddable/types'; import { fetchDashboard } from './dashboard_link_tools'; import { useNavigationEmbeddable } from '../../embeddable/navigation_embeddable'; +import { DASHBOARD_LINK_TYPE, NavigationEmbeddableLink } from '../../../common/content_management'; export const DashboardLinkComponent = ({ link }: { link: NavigationEmbeddableLink }) => { const navEmbeddable = useNavigationEmbeddable(); diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx index 90bf4066d4c20..7b940ac027357 100644 --- a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx @@ -9,11 +9,8 @@ import React from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; -import { - EXTERNAL_LINK_TYPE, - NavigationLinkInfo, - NavigationEmbeddableLink, -} from '../../embeddable/types'; +import { NavigationLinkInfo } from '../../embeddable/types'; +import { EXTERNAL_LINK_TYPE, NavigationEmbeddableLink } from '../../../common/content_management'; export const ExternalLinkComponent = ({ link }: { link: NavigationEmbeddableLink }) => { return ( diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx index b17be812accbc..a45d6d8028676 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx @@ -10,17 +10,22 @@ import React, { useMemo } from 'react'; import { EuiPanel } from '@elastic/eui'; -import { DASHBOARD_LINK_TYPE } from '../embeddable/types'; +import { DASHBOARD_LINK_TYPE } from '../../common/content_management'; import { useNavigationEmbeddable } from '../embeddable/navigation_embeddable'; import { ExternalLinkComponent } from './external_link/external_link_component'; import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; import { memoizedGetOrderedLinkList } from '../editor/navigation_embeddable_editor_tools'; +import { NavigationEmbeddableByValueInput } from '../embeddable/types'; export const NavigationEmbeddableComponent = () => { const navEmbeddable = useNavigationEmbeddable(); - const links = navEmbeddable.select((state) => state.explicitInput.links); + const links = navEmbeddable.select( + (state) => (state.explicitInput as NavigationEmbeddableByValueInput).attributes?.links + ); + const orderedLinks = useMemo(() => { + if (!links) return []; return memoizedGetOrderedLinkList(links); }, [links]); diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx index 1d5fa98766051..def291c63bcac 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx @@ -28,14 +28,14 @@ import { } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { NavigationLinkInfo } from '../embeddable/types'; import { - NavigationLinkInfo, NavigationLinkType, EXTERNAL_LINK_TYPE, DASHBOARD_LINK_TYPE, NavigationEmbeddableLink, - DashboardItem, -} from '../embeddable/types'; +} from '../../common/content_management'; +import { DashboardItem } from '../embeddable/types'; import { NavEmbeddableStrings } from './navigation_embeddable_strings'; import { NavigationEmbeddableUnorderedLink } from '../editor/open_link_editor_flyout'; import { ExternalLinkDestinationPicker } from './external_link/external_link_destination_picker'; @@ -177,7 +177,7 @@ export const NavigationEmbeddableLinkEditor = ({ {/* TODO: As part of https://github.com/elastic/kibana/issues/154381, we should pull in the custom settings for each link type. Refer to `x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/collect_config_container.tsx` - for the dashboard drilldown settings, for example. + for the dashboard drilldown settings, for example. Open question: It probably makes sense to re-use these components so any changes made to the drilldown architecture trickle down to the navigation embeddable - this would require some refactoring, though. Is this a goal for MVP? diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx index f4c2ce6b51492..97a5a86a9d653 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx @@ -17,7 +17,6 @@ import { EuiPanel, EuiSpacer, EuiButton, - EuiToolTip, EuiFormRow, EuiFlexItem, EuiFlexGroup, @@ -30,55 +29,63 @@ import { EuiFlyoutHeader, EuiDragDropContext, euiDragDropReorder, + EuiToolTip, } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { coreServices } from '../services/kibana_services'; -import { - NavigationEmbeddableLink, - NavigationEmbeddableInput, - NavigationEmbeddableLinkList, -} from '../embeddable/types'; +import { NavigationEmbeddableLink } from '../../common/content_management'; import { NavEmbeddableStrings } from './navigation_embeddable_strings'; import { openLinkEditorFlyout } from '../editor/open_link_editor_flyout'; import { memoizedGetOrderedLinkList } from '../editor/navigation_embeddable_editor_tools'; import { NavigationEmbeddablePanelEditorLink } from './navigation_embeddable_panel_editor_link'; +import { TooltipWrapper } from './tooltip_wrapper'; import noLinksIllustrationDark from '../assets/empty_links_dark.svg'; import noLinksIllustrationLight from '../assets/empty_links_light.svg'; - import './navigation_embeddable.scss'; const NavigationEmbeddablePanelEditor = ({ - onSave, + onSaveToLibrary, + onAddToDashboard, onClose, - initialInput, + initialLinks, parentDashboard, + isByReference, }: { + onSaveToLibrary: (newLinks: NavigationEmbeddableLink[]) => Promise; + onAddToDashboard: (newLinks: NavigationEmbeddableLink[]) => void; onClose: () => void; + initialLinks?: NavigationEmbeddableLink[]; parentDashboard?: DashboardContainer; - initialInput: Partial; - onSave: (input: Partial) => void; + isByReference: boolean; }) => { const isDarkTheme = useObservable(coreServices.theme.theme$)?.darkMode; + const toasts = coreServices.notifications.toasts; const editLinkFlyoutRef: React.RefObject = useMemo(() => React.createRef(), []); const [orderedLinks, setOrderedLinks] = useState([]); + const [isSaving, setIsSaving] = useState(false); + + const isEditingExisting = initialLinks || isByReference; useEffect(() => { - const { links: initialLinks } = initialInput; if (!initialLinks) { setOrderedLinks([]); return; } setOrderedLinks(memoizedGetOrderedLinkList(initialLinks)); - }, [initialInput]); + }, [initialLinks]); const onDragEnd = useCallback( ({ source, destination }) => { if (source && destination) { - const newList = euiDragDropReorder(orderedLinks, source.index, destination.index); + const newList = euiDragDropReorder(orderedLinks, source.index, destination.index).map( + (link, i) => { + return { ...link, order: i }; + } + ); setOrderedLinks(newList); } }, @@ -121,39 +128,13 @@ const NavigationEmbeddablePanelEditor = ({ [orderedLinks] ); - const saveButtonComponent = useMemo(() => { - const canSave = orderedLinks.length !== 0; - - const button = ( - { - const newLinks = orderedLinks.reduce((prev, link, i) => { - return { ...prev, [link.id]: { ...link, order: i } }; - }, {} as NavigationEmbeddableLinkList); - onSave({ links: newLinks }); - }} - > - {NavEmbeddableStrings.editor.panelEditor.getSaveButtonLabel()} - - ); - - return canSave ? ( - button - ) : ( - - {button} - - ); - }, [onSave, orderedLinks]); - return ( <>

- {initialInput.links && Object.keys(initialInput.links).length > 0 + {isEditingExisting ? NavEmbeddableStrings.editor.panelEditor.getEditFlyoutTitle() : NavEmbeddableStrings.editor.panelEditor.getCreateFlyoutTitle()}

@@ -227,11 +208,73 @@ const NavigationEmbeddablePanelEditor = ({ - + {NavEmbeddableStrings.editor.getCancelButtonLabel()} - {saveButtonComponent} + + + {!isByReference ? ( + + + { + onAddToDashboard(orderedLinks); + }} + > + {initialLinks + ? NavEmbeddableStrings.editor.panelEditor.getApplyButtonLabel() + : NavEmbeddableStrings.editor.panelEditor.getAddToDashboardButtonLabel()} + + + + ) : null} + {!initialLinks || isByReference ? ( + + + {initialLinks + ? NavEmbeddableStrings.editor.panelEditor.getUpdateLibraryItemButtonTooltip() + : NavEmbeddableStrings.editor.panelEditor.getSaveToLibraryButtonTooltip()} +

+ } + > + { + setIsSaving(true); + onSaveToLibrary(orderedLinks) + .catch((e) => { + toasts.addError(e, { + title: + NavEmbeddableStrings.editor.panelEditor.getErrorDuringSaveToastTitle(), + }); + }) + .finally(() => { + setIsSaving(false); + }); + }} + > + {initialLinks + ? NavEmbeddableStrings.editor.panelEditor.getUpdateLibraryItemButtonLabel() + : NavEmbeddableStrings.editor.panelEditor.getSaveToLibraryButtonLabel()} + +
+
+ ) : null} +
+
diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx index be65c130222eb..7c6d2b7268102 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx @@ -21,11 +21,8 @@ import { } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { - NavigationLinkInfo, - DASHBOARD_LINK_TYPE, - NavigationEmbeddableLink, -} from '../embeddable/types'; +import { NavigationLinkInfo } from '../embeddable/types'; +import { DASHBOARD_LINK_TYPE, NavigationEmbeddableLink } from '../../common/content_management'; import { fetchDashboard } from './dashboard_link/dashboard_link_tools'; import { NavEmbeddableStrings } from './navigation_embeddable_strings'; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts index 5628c3444d2d4..20953c41dbe8c 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts @@ -47,9 +47,38 @@ export const NavEmbeddableStrings = { i18n.translate('navigationEmbeddable.panelEditor.editFlyoutTitle', { defaultMessage: 'Edit links panel', }), - getSaveButtonLabel: () => - i18n.translate('navigationEmbeddable.panelEditor.saveButtonLabel', { - defaultMessage: 'Save', + getApplyButtonLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.applyButtonLabel', { + defaultMessage: 'Apply', + }), + getAddToDashboardButtonLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.addToDashboardButtonLabel', { + defaultMessage: 'Add to dashboard', + }), + getAddToDashboardButtonTooltip: () => + i18n.translate('navigationEmbeddable.panelEditor.addToDashboardButtonTooltip', { + defaultMessage: 'Add this links panel directly to this dashboard.', + }), + getSaveToLibraryButtonLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.saveToLibraryButtonLabel', { + defaultMessage: 'Save to library', + }), + getSaveToLibraryButtonTooltip: () => + i18n.translate('navigationEmbeddable.panelEditor.saveToLibraryButtonTooltip', { + defaultMessage: + 'Save this links panel to the library so you can easily add it to other dashboards.', + }), + getUpdateLibraryItemButtonLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.updateLibraryItemButtonLabel', { + defaultMessage: 'Update library item', + }), + getUpdateLibraryItemButtonTooltip: () => + i18n.translate('navigationEmbeddable.panelEditor.updateLibraryItemButtonTooltip', { + defaultMessage: 'Editing this panel might affect other dashboards.', + }), + getTitleInputLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.titleInputLabel', { + defaultMessage: 'Title', }), getLinkLoadingAriaLabel: () => i18n.translate('navigationEmbeddable.linkEditor.linkLoadingAriaLabel', { @@ -59,6 +88,10 @@ export const NavEmbeddableStrings = { i18n.translate('navigationEmbeddable.editor.dragHandleAriaLabel', { defaultMessage: 'Link drag handle', }), + getErrorDuringSaveToastTitle: () => + i18n.translate('navigationEmbeddable.editor.unableToSaveToastTitle', { + defaultMessage: 'Error saving Link panel', + }), }, linkEditor: { getGoBackAriaLabel: () => diff --git a/src/plugins/navigation_embeddable/public/components/tooltip_wrapper.tsx b/src/plugins/navigation_embeddable/public/components/tooltip_wrapper.tsx new file mode 100644 index 0000000000000..a477c62d3bd9c --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/tooltip_wrapper.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiToolTip, EuiToolTipProps } from '@elastic/eui'; + +type TooltipWrapperProps = Partial> & { + tooltipContent: string; + /** When the condition is truthy, the tooltip will be shown */ + condition: boolean; +}; + +export const TooltipWrapper: React.FunctionComponent = ({ + children, + condition, + tooltipContent, + ...tooltipProps +}) => { + return ( + <> + {condition ? ( + + <>{children} + + ) : ( + children + )} + + ); +}; diff --git a/src/plugins/navigation_embeddable/public/content_management/duplicate_title_check.ts b/src/plugins/navigation_embeddable/public/content_management/duplicate_title_check.ts new file mode 100644 index 0000000000000..3115e110467e8 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/content_management/duplicate_title_check.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { navigationEmbeddableClient } from './navigation_embeddable_content_management_client.ts'; + +const rejectErrorMessage = i18n.translate('navigationEmbeddable.saveDuplicateRejectedDescription', { + defaultMessage: 'Save with duplicate title confirmation was rejected', +}); + +interface Props { + title: string; + id?: string; + onTitleDuplicate: () => void; + lastSavedTitle: string; + copyOnSave: boolean; + isTitleDuplicateConfirmed: boolean; +} + +export const checkForDuplicateTitle = async ({ + id, + title, + lastSavedTitle, + copyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, +}: Props) => { + if (isTitleDuplicateConfirmed) { + return true; + } + + if (title === lastSavedTitle && !copyOnSave) { + return true; + } + + const { hits } = await navigationEmbeddableClient.search( + { + text: `"${title}"`, + limit: 10, + }, + { onlyTitle: true } + ); + + const existing = hits.find((obj) => obj.attributes.title.toLowerCase() === title.toLowerCase()); + + if (!existing || existing.id === id) { + return true; + } + + onTitleDuplicate(); + return Promise.reject(new Error(rejectErrorMessage)); +}; diff --git a/src/plugins/navigation_embeddable/public/content_management/index.ts b/src/plugins/navigation_embeddable/public/content_management/index.ts new file mode 100644 index 0000000000000..883a28a34ad24 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/content_management/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { navigationEmbeddableClient } from './navigation_embeddable_content_management_client.ts'; +export { checkForDuplicateTitle } from './duplicate_title_check'; diff --git a/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts b/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts new file mode 100644 index 0000000000000..f7cb54da23937 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SearchQuery } from '@kbn/content-management-plugin/common'; +import type { NavigationEmbeddableCrudTypes } from '../../common/content_management'; +import { CONTENT_ID as contentTypeId } from '../../common'; +import { contentManagement } from '../services/kibana_services'; + +const get = async (id: string) => { + return contentManagement.client.get< + NavigationEmbeddableCrudTypes['GetIn'], + NavigationEmbeddableCrudTypes['GetOut'] + >({ contentTypeId, id }); +}; + +const create = async ({ + data, + options, +}: Omit) => { + const res = await contentManagement.client.create< + NavigationEmbeddableCrudTypes['CreateIn'], + NavigationEmbeddableCrudTypes['CreateOut'] + >({ + contentTypeId, + data, + options, + }); + return res; +}; + +const update = async ({ + id, + data, + options, +}: Omit) => { + const res = await contentManagement.client.update< + NavigationEmbeddableCrudTypes['UpdateIn'], + NavigationEmbeddableCrudTypes['UpdateOut'] + >({ + contentTypeId, + id, + data, + options, + }); + return res; +}; + +const deleteNavigationEmbeddable = async (id: string) => { + await contentManagement.client.delete< + NavigationEmbeddableCrudTypes['DeleteIn'], + NavigationEmbeddableCrudTypes['DeleteOut'] + >({ + contentTypeId, + id, + }); +}; + +const search = async ( + query: SearchQuery = {}, + options?: NavigationEmbeddableCrudTypes['SearchOptions'] +) => { + return contentManagement.client.search< + NavigationEmbeddableCrudTypes['SearchIn'], + NavigationEmbeddableCrudTypes['SearchOut'] + >({ + contentTypeId, + query, + options, + }); +}; + +export const navigationEmbeddableClient = { + get, + create, + update, + delete: deleteNavigationEmbeddable, + search, +}; diff --git a/src/plugins/navigation_embeddable/public/content_management/save_to_library.tsx b/src/plugins/navigation_embeddable/public/content_management/save_to_library.tsx new file mode 100644 index 0000000000000..31274817c5cb0 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/content_management/save_to_library.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { + showSaveModal, + OnSaveProps, + SavedObjectSaveModal, + SaveResult, +} from '@kbn/saved-objects-plugin/public'; + +import { APP_NAME } from '../../common'; +import { NavigationEmbeddableAttributes } from '../../common/content_management'; +import { + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableInput, +} from '../embeddable/types'; +import { checkForDuplicateTitle } from './duplicate_title_check'; +import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; + +export const runSaveToLibrary = async ( + newAttributes: NavigationEmbeddableAttributes, + initialInput: NavigationEmbeddableInput +): Promise => { + return new Promise((resolve) => { + const onSave = async ({ + newTitle, + newDescription, + onTitleDuplicate, + isTitleDuplicateConfirmed, + }: OnSaveProps): Promise => { + const stateFromSaveModal = { + title: newTitle, + description: newDescription, + }; + + if ( + !(await checkForDuplicateTitle({ + title: newTitle, + lastSavedTitle: newAttributes.title, + copyOnSave: false, + onTitleDuplicate, + isTitleDuplicateConfirmed, + })) + ) { + return {}; + } + + const stateToSave = { + ...newAttributes, + ...stateFromSaveModal, + }; + + const updatedInput = (await getNavigationEmbeddableAttributeService().wrapAttributes( + stateToSave, + true, + initialInput + )) as unknown as NavigationEmbeddableByReferenceInput; + + resolve(updatedInput); + return { id: updatedInput.savedObjectId }; + }; + + const saveModal = ( + resolve(undefined)} + title={newAttributes.title} + description={newAttributes.description} + showDescription + showCopyOnSave={false} + objectType={APP_NAME} + /> + ); + showSaveModal(saveModal); + }); +}; diff --git a/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx b/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx index 83cc4cfdc7c40..4248af756f525 100644 --- a/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx +++ b/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx @@ -7,16 +7,12 @@ */ import { memoize } from 'lodash'; -import { NavigationEmbeddableLink, NavigationEmbeddableLinkList } from '../embeddable/types'; +import { NavigationEmbeddableLink } from '../../common/content_management'; -const getOrderedLinkList = (links: NavigationEmbeddableLinkList): NavigationEmbeddableLink[] => { - return Object.keys(links) - .map((linkId) => { - return links[linkId]; - }) - .sort((linkA, linkB) => { - return linkA.order - linkB.order; - }); +const getOrderedLinkList = (links: NavigationEmbeddableLink[]): NavigationEmbeddableLink[] => { + return [...links].sort((linkA, linkB) => { + return linkA.order - linkB.order; + }); }; /** @@ -25,10 +21,10 @@ const getOrderedLinkList = (links: NavigationEmbeddableLinkList): NavigationEmbe * calculated this so, we can get away with using the cached version in the editor */ export const memoizedGetOrderedLinkList = memoize( - (links: NavigationEmbeddableLinkList) => { + (links: NavigationEmbeddableLink[]) => { return getOrderedLinkList(links); }, - (links) => { + (links: NavigationEmbeddableLink[]) => { return links; } ); diff --git a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx index 19edb5fb4f2c8..e156180d8dd4d 100644 --- a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx +++ b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx @@ -17,8 +17,14 @@ import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { coreServices } from '../services/kibana_services'; -import { NavigationEmbeddableInput } from '../embeddable/types'; +import { + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableInput, +} from '../embeddable/types'; import { memoizedFetchDashboards } from '../components/dashboard_link/dashboard_link_tools'; +import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; +import { NavigationEmbeddableLink } from '../../common/content_management'; +import { runSaveToLibrary } from '../content_management/save_to_library'; const LazyNavigationEmbeddablePanelEditor = React.lazy( () => import('../components/navigation_embeddable_panel_editor') @@ -35,14 +41,42 @@ const NavigationEmbeddablePanelEditor = withSuspense( * @throws in case user cancels */ export async function openEditorFlyout( - initialInput?: Omit, + initialInput: NavigationEmbeddableInput, parentDashboard?: DashboardContainer ): Promise> { + const attributeService = getNavigationEmbeddableAttributeService(); + const { attributes } = await attributeService.unwrapAttributes(initialInput); + const isByReference = attributeService.inputIsRefType(initialInput); + return new Promise((resolve, reject) => { const closed$ = new Subject(); - const onSave = (partialInput: Partial) => { - resolve(partialInput); + const onSaveToLibrary = async (newLinks: NavigationEmbeddableLink[]) => { + const newAttributes = { + ...attributes, + links: newLinks, + }; + const updatedInput = (initialInput as NavigationEmbeddableByReferenceInput).savedObjectId + ? await attributeService.wrapAttributes(newAttributes, true, initialInput) + : await runSaveToLibrary(newAttributes, initialInput); + if (!updatedInput) { + return; + } + resolve(updatedInput); + parentDashboard?.reload(); + editorFlyout.close(); + }; + + const onAddToDashboard = (newLinks: NavigationEmbeddableLink[]) => { + const newInput: NavigationEmbeddableInput = { + ...initialInput, + attributes: { + ...attributes, + links: newLinks, + }, + }; + resolve(newInput); + parentDashboard?.reload(); editorFlyout.close(); }; @@ -63,10 +97,12 @@ export async function openEditorFlyout( const editorFlyout = coreServices.overlays.openFlyout( toMountPoint( , { theme$: coreServices.theme.theme$ } ), diff --git a/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx index 794b8812d793b..1fee9bdd9b206 100644 --- a/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx +++ b/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx @@ -13,7 +13,7 @@ import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { coreServices } from '../services/kibana_services'; -import { NavigationEmbeddableLink } from '../embeddable/types'; +import { NavigationEmbeddableLink } from '../../common/content_management'; import { NavigationEmbeddableLinkEditor } from '../components/navigation_embeddable_link_editor'; export interface LinkEditorProps { diff --git a/src/plugins/navigation_embeddable/public/embeddable/index.ts b/src/plugins/navigation_embeddable/public/embeddable/index.ts index 12c60f3ebd004..eeaae05334801 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/index.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/index.ts @@ -6,9 +6,6 @@ * Side Public License, v 1. */ -export { - NAVIGATION_EMBEDDABLE_TYPE, - NavigationEmbeddable as NavigationEmbeddable, -} from './navigation_embeddable'; +export { NavigationEmbeddable } from './navigation_embeddable'; export type { NavigationEmbeddableFactory } from './navigation_embeddable_factory'; export { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable_factory'; diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx index 9f3194d0b376c..58a665488b825 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx @@ -7,16 +7,27 @@ */ import React, { createContext, useContext } from 'react'; - -import { Embeddable, EmbeddableOutput } from '@kbn/embeddable-plugin/public'; +import { Subscription } from 'rxjs'; + +import { + AttributeService, + Embeddable, + ReferenceOrValueEmbeddable, + SavedObjectEmbeddableInput, +} from '@kbn/embeddable-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; import { navigationEmbeddableReducers } from './navigation_embeddable_reducers'; -import { NavigationEmbeddableInput, NavigationEmbeddableReduxState } from './types'; +import { + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableByValueInput, + NavigationEmbeddableReduxState, +} from './types'; import { NavigationEmbeddableComponent } from '../components/navigation_embeddable_component'; - -export const NAVIGATION_EMBEDDABLE_TYPE = 'navigation'; +import { NavigationEmbeddableInput, NavigationEmbeddableOutput } from './types'; +import { NavigationEmbeddableAttributes } from '../../common/content_management'; +import { CONTENT_ID } from '../../common'; export const NavigationEmbeddableContext = createContext(null); export const useNavigationEmbeddable = (): NavigationEmbeddable => { @@ -36,8 +47,19 @@ export interface NavigationEmbeddableConfig { editable: boolean; } -export class NavigationEmbeddable extends Embeddable { - public readonly type = NAVIGATION_EMBEDDABLE_TYPE; +export class NavigationEmbeddable + extends Embeddable + implements + ReferenceOrValueEmbeddable< + NavigationEmbeddableByValueInput, + NavigationEmbeddableByReferenceInput + > +{ + public readonly type = CONTENT_ID; + deferEmbeddableLoad = true; + + private isDestroyed?: boolean; + private subscriptions: Subscription = new Subscription(); // state management public select: NavigationReduxEmbeddableTools['select']; @@ -51,6 +73,7 @@ export class NavigationEmbeddable extends Embeddable, parent?: DashboardContainer ) { super( @@ -77,17 +100,66 @@ export class NavigationEmbeddable extends Embeddable this.setInitializationFinished()) + .catch((e: Error) => this.onFatalError(e)); } - public async reload() {} + private async initializeSavedLinks(input: NavigationEmbeddableInput) { + const { attributes } = await this.attributeService.unwrapAttributes(input); + if (this.isDestroyed) return; + + // TODO handle metaInfo + + this.updateInput({ attributes }); + + await this.initializeOutput(); + } + + private async initializeOutput() { + const { attributes } = this.getInput() as NavigationEmbeddableByValueInput; + const { title, description } = this.getInput(); + this.updateOutput({ + defaultTitle: attributes.title, + defaultDescription: attributes.description, + title: title ?? attributes.title, + description: description ?? attributes.description, + }); + } + + public inputIsRefType( + input: NavigationEmbeddableByValueInput | NavigationEmbeddableByReferenceInput + ): input is NavigationEmbeddableByReferenceInput { + return this.attributeService.inputIsRefType(input); + } + + public async getInputAsRefType(): Promise { + return this.attributeService.getInputAsRefType(this.getExplicitInput(), { + showSaveModal: true, + saveModalTitle: this.getTitle(), + }); + } + + public async getInputAsValueType(): Promise { + return this.attributeService.getInputAsValueType(this.getExplicitInput()); + } + + public async reload() { + if (this.isDestroyed) return; + await this.initializeSavedLinks(this.getInput()); + this.render(); + } public destroy() { + this.isDestroyed = true; super.destroy(); + this.subscriptions.unsubscribe(); this.cleanupStateTools(); } public render() { + if (this.isDestroyed) return; return ( diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts index 8a9662492909a..9711c81b64125 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts @@ -6,34 +6,63 @@ * Side Public License, v 1. */ -import { isEmpty } from 'lodash'; - -import { i18n } from '@kbn/i18n'; import { ACTION_ADD_PANEL, EmbeddableFactory, EmbeddableFactoryDefinition, + EmbeddablePackageState, + ErrorEmbeddable, } from '@kbn/embeddable-plugin/public'; import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { NavigationEmbeddableInput } from './types'; -import { NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; +import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; +import { + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableByValueInput, + NavigationEmbeddableInput, +} from './types'; +import type { NavigationEmbeddable } from './navigation_embeddable'; import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; +import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; +import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; export type NavigationEmbeddableFactory = EmbeddableFactory; +export interface NavigationEmbeddableCreationOptions { + getInitialInput?: () => Partial; + getIncomingEmbeddable?: () => EmbeddablePackageState | undefined; +} + // TODO: Replace string 'OPEN_FLYOUT_ADD_DRILLDOWN' with constant as part of https://github.com/elastic/kibana/issues/154381 -const getDefaultNavigationEmbeddableInput = (): Omit => ({ - links: {}, +const getDefaultNavigationEmbeddableInput = (): Omit => ({ + attributes: { + title: '', + }, disabledActions: [ACTION_ADD_PANEL, 'OPEN_FLYOUT_ADD_DRILLDOWN'], }); export class NavigationEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { - public readonly type = NAVIGATION_EMBEDDABLE_TYPE; - public isContainerType = false; + public readonly type = CONTENT_ID; + + public readonly isContainerType = false; + + public readonly savedObjectMetaData = { + name: APP_NAME, + type: CONTENT_ID, + getIconForSavedObject: () => APP_ICON, + }; + + // TODO create functions + // public inject: EmbeddablePersistableStateService['inject']; + // public extract: EmbeddablePersistableStateService['extract']; + + constructor(persistableStateService: EmbeddablePersistableStateService) { + // this.inject = createInject(this.persistableStateService); + // this.extract = createExtract(this.persistableStateService); + } public async isEditable() { await untilPluginStartServicesReady(); @@ -48,12 +77,18 @@ export class NavigationEmbeddableFactoryDefinition return getDefaultNavigationEmbeddableInput(); } - public async create(initialInput: NavigationEmbeddableInput, parent: DashboardContainer) { - if (!initialInput.links || isEmpty(initialInput.links)) { - // don't create an empty navigation embeddable - it should always have at least one link - return; + public async createFromSavedObject( + savedObjectId: string, + input: NavigationEmbeddableInput, + parent: DashboardContainer + ): Promise { + if (!(input as NavigationEmbeddableByReferenceInput).savedObjectId) { + (input as NavigationEmbeddableByReferenceInput).savedObjectId = savedObjectId; } + return this.create(input, parent); + } + public async create(initialInput: NavigationEmbeddableInput, parent: DashboardContainer) { await untilPluginStartServicesReady(); const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage(); @@ -64,33 +99,32 @@ export class NavigationEmbeddableFactoryDefinition reduxEmbeddablePackage, { editable }, { ...getDefaultNavigationEmbeddableInput(), ...initialInput }, + getNavigationEmbeddableAttributeService(), parent ); } public async getExplicitInput( - initialInput?: NavigationEmbeddableInput, + initialInput: NavigationEmbeddableInput, parent?: DashboardContainer - ) { + ): Promise> { if (!parent) return {}; const { openEditorFlyout } = await import('../editor/open_editor_flyout'); const input = await openEditorFlyout( - { ...getDefaultNavigationEmbeddableInput(), ...initialInput }, + { + ...getDefaultNavigationEmbeddableInput(), + ...initialInput, + }, parent - ).catch(() => { - // swallow the promise rejection that happens when the flyout is closed - return {}; - }); + ); return input; } public getDisplayName() { - return i18n.translate('navigationEmbeddable.navigationEmbeddableFactory.displayName', { - defaultMessage: 'Links', - }); + return APP_NAME; } public getIconType() { diff --git a/src/plugins/navigation_embeddable/public/embeddable/types.ts b/src/plugins/navigation_embeddable/public/embeddable/types.ts index 0513d50fc8cb8..43d12f611df8b 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/types.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/types.ts @@ -6,48 +6,28 @@ * Side Public License, v 1. */ -import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; import { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; -import { EmbeddableInput, EmbeddableOutput } from '@kbn/embeddable-plugin/public'; +import { + EmbeddableInput, + EmbeddableOutput, + SavedObjectEmbeddableInput, +} from '@kbn/embeddable-plugin/public'; +import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; import { ExternalLinkEmbeddableStrings } from '../components/external_link/external_link_strings'; import { DashboardLinkEmbeddableStrings } from '../components/dashboard_link/dashboard_link_strings'; +import { + DASHBOARD_LINK_TYPE, + EXTERNAL_LINK_TYPE, + NavigationLinkType, + NavigationEmbeddableAttributes, +} from '../../common/content_management'; -/** - * Dashboard to dashboard links - */ -export const DASHBOARD_LINK_TYPE = 'dashboardLink'; export interface DashboardItem { id: string; attributes: DashboardAttributes; } -/** - * External URL links - */ -export const EXTERNAL_LINK_TYPE = 'externalLink'; - -/** - * Navigation embeddable explicit input - */ -export type NavigationLinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; - -export interface NavigationEmbeddableLink { - id: string; - type: NavigationLinkType; - destination: string; - label?: string; - order: number; -} - -export interface NavigationEmbeddableLinkList { - [id: string]: NavigationEmbeddableLink; -} - -export interface NavigationEmbeddableInput extends EmbeddableInput { - links: NavigationEmbeddableLinkList; -} - export const NavigationLinkInfo: { [id in NavigationLinkType]: { icon: string; displayName: string; description: string }; } = { @@ -63,6 +43,20 @@ export const NavigationLinkInfo: { }, }; +export type NavigationEmbeddableByValueInput = { + attributes: NavigationEmbeddableAttributes; +} & EmbeddableInput; + +export type NavigationEmbeddableByReferenceInput = SavedObjectEmbeddableInput; + +export type NavigationEmbeddableInput = + | NavigationEmbeddableByValueInput + | NavigationEmbeddableByReferenceInput; + +export type NavigationEmbeddableOutput = EmbeddableOutput & { + attributes?: NavigationEmbeddableAttributes; +}; + /** * Navigation embeddable redux state */ @@ -70,6 +64,6 @@ export const NavigationLinkInfo: { export type NavigationEmbeddableReduxState = ReduxEmbeddableState< NavigationEmbeddableInput, - EmbeddableOutput, + NavigationEmbeddableOutput, {} // We currently don't have any component state - TODO: Replace with `NavigationEmbeddableComponentState` if necessary >; diff --git a/src/plugins/navigation_embeddable/public/index.ts b/src/plugins/navigation_embeddable/public/index.ts index 9cdbcfcc6c667..d1655bd9bc25d 100644 --- a/src/plugins/navigation_embeddable/public/index.ts +++ b/src/plugins/navigation_embeddable/public/index.ts @@ -7,11 +7,7 @@ */ export type { NavigationEmbeddableFactory } from './embeddable'; -export { - NAVIGATION_EMBEDDABLE_TYPE, - NavigationEmbeddableFactoryDefinition, - NavigationEmbeddable, -} from './embeddable'; +export { NavigationEmbeddableFactoryDefinition, NavigationEmbeddable } from './embeddable'; import { NavigationEmbeddablePlugin } from './plugin'; diff --git a/src/plugins/navigation_embeddable/public/plugin.ts b/src/plugins/navigation_embeddable/public/plugin.ts index 7a969c0298f7a..8863c4393b058 100644 --- a/src/plugins/navigation_embeddable/public/plugin.ts +++ b/src/plugins/navigation_embeddable/public/plugin.ts @@ -6,20 +6,26 @@ * Side Public License, v 1. */ -import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { + ContentManagementPublicSetup, + ContentManagementPublicStart, +} from '@kbn/content-management-plugin/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; - -import { NAVIGATION_EMBEDDABLE_TYPE } from './embeddable'; -import { setKibanaServices } from './services/kibana_services'; import { NavigationEmbeddableFactoryDefinition } from './embeddable'; +import { CONTENT_ID, LATEST_VERSION } from '../common'; +import { APP_NAME } from '../common'; +import { setKibanaServices } from './services/kibana_services'; export interface NavigationEmbeddableSetupDependencies { embeddable: EmbeddableSetup; + contentManagement: ContentManagementPublicSetup; } export interface NavigationEmbeddableStartDependencies { embeddable: EmbeddableStart; + contentManagement: ContentManagementPublicStart; dashboard: DashboardStart; } @@ -40,14 +46,23 @@ export class NavigationEmbeddablePlugin ) { core.getStartServices().then(([_, deps]) => { plugins.embeddable.registerEmbeddableFactory( - NAVIGATION_EMBEDDABLE_TYPE, - new NavigationEmbeddableFactoryDefinition() + CONTENT_ID, + new NavigationEmbeddableFactoryDefinition(deps.embeddable) ); + + plugins.contentManagement.registry.register({ + id: CONTENT_ID, + version: { + latest: LATEST_VERSION, + }, + name: APP_NAME, + }); }); } public start(core: CoreStart, plugins: NavigationEmbeddableStartDependencies) { setKibanaServices(core, plugins); + return {}; } public stop() {} diff --git a/src/plugins/navigation_embeddable/public/services/attribute_service.ts b/src/plugins/navigation_embeddable/public/services/attribute_service.ts new file mode 100644 index 0000000000000..7a7dbfe2bd139 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/services/attribute_service.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Reference } from '@kbn/content-management-utils'; +import { AttributeService } from '@kbn/embeddable-plugin/public'; +import type { OnSaveProps } from '@kbn/saved-objects-plugin/public'; +import { SharingSavedObjectProps } from '../../common/types'; +import { NavigationEmbeddableAttributes } from '../../common/content_management'; +import { + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableByValueInput, +} from '../embeddable/types'; +import { embeddableService } from './kibana_services'; +import { checkForDuplicateTitle, navigationEmbeddableClient } from '../content_management'; +import { CONTENT_ID } from '../../common'; + +export type NavigationEmbeddableDocument = NavigationEmbeddableAttributes & { + references?: Reference[]; +}; + +export interface NavigationEmbeddableUnwrapMetaInfo { + sharingSavedObjectProps?: SharingSavedObjectProps; +} + +export type NavigationEmbeddableAttributeService = AttributeService< + NavigationEmbeddableDocument, + NavigationEmbeddableByValueInput, + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableUnwrapMetaInfo +>; + +let navigationEmbeddableAttributeService: NavigationEmbeddableAttributeService | null = null; +export function getNavigationEmbeddableAttributeService(): NavigationEmbeddableAttributeService { + if (navigationEmbeddableAttributeService) return navigationEmbeddableAttributeService; + + navigationEmbeddableAttributeService = embeddableService.getAttributeService< + NavigationEmbeddableDocument, + NavigationEmbeddableByValueInput, + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableUnwrapMetaInfo + >(CONTENT_ID, { + saveMethod: async (attributes: NavigationEmbeddableDocument, savedObjectId?: string) => { + // TODO extract references + const { + item: { id }, + } = await (savedObjectId + ? navigationEmbeddableClient.update({ id: savedObjectId, data: attributes }) + : navigationEmbeddableClient.create({ data: attributes, options: { references: [] } })); + return { id }; + }, + unwrapMethod: async ( + savedObjectId: string + ): Promise<{ + attributes: NavigationEmbeddableDocument; + metaInfo: NavigationEmbeddableUnwrapMetaInfo; + }> => { + const { + item: savedObject, + meta: { outcome, aliasPurpose, aliasTargetId }, + } = await navigationEmbeddableClient.get(savedObjectId); + if (savedObject.error) throw savedObject.error; + + // TODO inject references + const attributes = savedObject.attributes; + return { + attributes, + metaInfo: { + sharingSavedObjectProps: { + aliasTargetId, + outcome, + aliasPurpose, + sourceId: savedObjectId, + }, + }, + }; + }, + checkForDuplicateTitle: (props: OnSaveProps) => { + return checkForDuplicateTitle({ + title: props.newTitle, + copyOnSave: false, + lastSavedTitle: '', + isTitleDuplicateConfirmed: props.isTitleDuplicateConfirmed, + onTitleDuplicate: props.onTitleDuplicate, + }); + }, + }); + return navigationEmbeddableAttributeService; +} diff --git a/src/plugins/navigation_embeddable/public/services/kibana_services.ts b/src/plugins/navigation_embeddable/public/services/kibana_services.ts index 710c6227a3568..ddc5daad6495a 100644 --- a/src/plugins/navigation_embeddable/public/services/kibana_services.ts +++ b/src/plugins/navigation_embeddable/public/services/kibana_services.ts @@ -10,11 +10,15 @@ import { BehaviorSubject } from 'rxjs'; import { CoreStart } from '@kbn/core/public'; import { DashboardStart } from '@kbn/dashboard-plugin/public'; +import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import { NavigationEmbeddableStartDependencies } from '../plugin'; export let coreServices: CoreStart; export let dashboardServices: DashboardStart; +export let embeddableService: EmbeddableStart; +export let contentManagement: ContentManagementPublicStart; const servicesReady$ = new BehaviorSubject(false); @@ -36,6 +40,8 @@ export const setKibanaServices = ( ) => { coreServices = kibanaCore; dashboardServices = deps.dashboard; + embeddableService = deps.embeddable; + contentManagement = deps.contentManagement; servicesReady$.next(true); }; diff --git a/src/plugins/navigation_embeddable/server/content_management/index.ts b/src/plugins/navigation_embeddable/server/content_management/index.ts new file mode 100644 index 0000000000000..2376765bcac83 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/content_management/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { NavigationEmbeddableStorage } from './navigation_embeddable_storage'; diff --git a/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts b/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts new file mode 100644 index 0000000000000..07318f62a3e10 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SOContentStorage } from '@kbn/content-management-utils'; +import { CONTENT_ID } from '../../common'; +import type { NavigationEmbeddableCrudTypes } from '../../common/content_management'; +import { cmServicesDefinition } from '../../common/content_management/cm_services'; + +export class NavigationEmbeddableStorage extends SOContentStorage { + constructor() { + super({ + savedObjectType: CONTENT_ID, + cmServicesDefinition, + enableMSearch: true, + allowedSavedObjectAttributes: ['id', 'title', 'description', 'links'], + }); + } +} diff --git a/src/plugins/navigation_embeddable/server/index.ts b/src/plugins/navigation_embeddable/server/index.ts new file mode 100644 index 0000000000000..6ececdd95b5d0 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { NavigationEmbeddableServerPlugin } from './plugin'; + +export const plugin = () => new NavigationEmbeddableServerPlugin(); diff --git a/src/plugins/navigation_embeddable/server/plugin.ts b/src/plugins/navigation_embeddable/server/plugin.ts new file mode 100644 index 0000000000000..05e3ca79f9971 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/plugin.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; +import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; +import { CONTENT_ID, LATEST_VERSION } from '../common'; +import { NavigationEmbeddableAttributes } from '../common/content_management'; +import { NavigationEmbeddableStorage } from './content_management'; +import { navigationEmbeddableSavedObjectType } from './saved_objects'; + +export class NavigationEmbeddableServerPlugin implements Plugin { + public setup( + core: CoreSetup, + plugins: { + contentManagement: ContentManagementServerSetup; + } + ) { + plugins.contentManagement.register({ + id: CONTENT_ID, + storage: new NavigationEmbeddableStorage(), + version: { + latest: LATEST_VERSION, + }, + }); + + core.savedObjects.registerType( + navigationEmbeddableSavedObjectType + ); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/navigation_embeddable/server/saved_objects/index.ts b/src/plugins/navigation_embeddable/server/saved_objects/index.ts new file mode 100644 index 0000000000000..1c33d59959426 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/saved_objects/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { navigationEmbeddableSavedObjectType } from './navigation_embeddable'; diff --git a/src/plugins/navigation_embeddable/server/saved_objects/navigation_embeddable.ts b/src/plugins/navigation_embeddable/server/saved_objects/navigation_embeddable.ts new file mode 100644 index 0000000000000..0a2bfbd97897a --- /dev/null +++ b/src/plugins/navigation_embeddable/server/saved_objects/navigation_embeddable.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectsType } from '@kbn/core/server'; +import { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { APP_ICON, CONTENT_ID } from '../../common'; + +export const navigationEmbeddableSavedObjectType: SavedObjectsType = { + name: CONTENT_ID, + indexPattern: ANALYTICS_SAVED_OBJECT_INDEX, + hidden: false, + namespaceType: 'multiple', + management: { + icon: APP_ICON, + defaultSearchField: 'title', + importableAndExportable: true, + getTitle(obj) { + return obj.attributes.title; + }, + }, + mappings: { + properties: { + id: { type: 'text' }, + title: { type: 'text' }, + description: { type: 'text' }, + links: { + dynamic: false, + properties: {}, + }, + }, + }, + migrations: () => { + return {}; + }, +}; diff --git a/src/plugins/navigation_embeddable/tsconfig.json b/src/plugins/navigation_embeddable/tsconfig.json index 3c1cee2edb3d7..ae66fa7cc3144 100644 --- a/src/plugins/navigation_embeddable/tsconfig.json +++ b/src/plugins/navigation_embeddable/tsconfig.json @@ -11,7 +11,15 @@ "@kbn/embeddable-plugin", "@kbn/kibana-react-plugin", "@kbn/presentation-util-plugin", + "@kbn/object-versioning", + "@kbn/config-schema", + "@kbn/content-management-utils", + "@kbn/content-management-plugin", "@kbn/shared-ux-utility", + "@kbn/core-saved-objects-api-server", + "@kbn/saved-objects-plugin", + "@kbn/core-saved-objects-server", + "@kbn/saved-objects-plugin", ], "exclude": ["target/**/*"] } From f0ebcb21d7df46c32655e5f39f2a5a945bd5238e Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 16 Aug 2023 11:19:47 -0600 Subject: [PATCH 08/53] [Dashboard Navigation] Add horizontal/vertical embeddable rendering + error handling (#162285) Closes https://github.com/elastic/kibana/issues/154357 Closes https://github.com/elastic/kibana/issues/161563 ## Summary > **Warning** > I will be waiting to merge this PR until **after** https://github.com/elastic/kibana/pull/160896 is merged - I am simply opening it early so that we can start the design review process :+1: ### Layout This PR improves the rendering of the navigation embeddable to include both a horizontal and vertical layout option, as well as changing the style of how the links are rendered: https://github.com/elastic/kibana/assets/8698078/37d27683-a6c4-4e7a-9589-0eb0fb899e98 A known issue with the horizontal layout is that, as demonstrated in the above video, a "compact" horizontal navigation panel does not render as nicely in edit mode versus view mode - this is an **overall panel problem** and not specifically a problem with the navigation embeddable (although the navigation embeddable definitely makes it more obvious). This will be resolved for **all panels** by [removing the panel header altogether](https://github.com/elastic/kibana/issues/162182). ### Error handling This PR adds proper error handling to the navigation embeddable so that, if a dashboard link is "broken" (i.e. the destination dashboard has been deleted or cannot be fetched), an appropriate error message shows up in both the component and the editor flyout: https://github.com/elastic/kibana/assets/8698078/33a3e573-36a2-47ca-b367-3e04f9541ca3 > **Note** > When possible, we want to provide the user with as much context as possible for broken dashboard links - that is why, if a dashboard link was given a custom label, we still show this custom label even when the destination dashboard has been deleted/is unreachable. > > However, once a dashboard has been deleted, we no longer know what the title of that dashboard was because the saved object no longer exists - so, if a dashboard link is **not** given a custom label and the destination dashboard is deleted, we default to the "Error fetching dashboard" error message instead. In order to create a distinction between these two scenarios (a broken dashboard link with a custom label versus without), we italicize the generic "Error fetching dashboard" error text. ### Improved efficiency Previously, the navigation embeddable was handling its **own** dashboard cache, which meant that (a) every single embeddable had its own cache and (b) the navigation embeddable code had to be mindful when choosing to use the memoized/cached version of the dashboard versus fetching it fresh. After discussing with @ThomThomson about how to better handle this, we opted to move this logic to the dashboard content management service - not only does this clean up the navigation embeddable code, it also improves all the loading of dashboards in general. For example, consider the following video where I was testing re-loading a previously loaded dashboard on a throttled `Slow 3G` network: https://github.com/elastic/kibana/assets/8698078/41d68ac7-557c-4586-a59b-7268086991dd Notice in the above video how much faster the secondary load of the dashboard is in comparison to the first initial load - this is because, in the second load, we can hit the cache instead of re-fetching the dashboard from the content management client, which allows us to skip an entire loading state. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] ~[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~ Will be addressed in https://github.com/elastic/kibana/issues/161287 - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Andrea Del Rio Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../current_mappings.json | 1 + .../dashboard/public/dashboard_constants.ts | 6 + .../dashboard_content_management.stub.ts | 1 + .../dashboard_content_management_cache.ts | 40 ++++ .../dashboard_content_management_service.ts | 5 + .../lib/delete_dashboards.ts | 10 +- .../lib/find_dashboards.ts | 43 +++- .../lib/load_dashboard_state.ts | 31 ++- .../lib/save_dashboard_state.ts | 3 + .../dashboard_content_management/types.ts | 1 + .../navigation_embeddable/common/constants.ts | 7 + .../common/content_management/index.ts | 14 +- .../content_management/v1/cm_services.ts | 10 +- .../common/content_management/v1/constants.ts | 8 +- .../common/content_management/v1/index.ts | 8 +- .../common/content_management/v1/types.ts | 10 +- .../navigation_embeddable/public/_mixins.scss | 16 +- .../dashboard_link_component.tsx | 129 ++++++++--- .../dashboard_link_destination_picker.tsx | 27 ++- .../dashboard_link/dashboard_link_strings.ts | 14 +- .../dashboard_link/dashboard_link_tools.tsx | 44 +--- .../navigation_embeddable_editor.scss} | 33 ++- .../navigation_embeddable_link_editor.tsx | 15 +- .../navigation_embeddable_panel_editor.tsx | 210 +++++++++--------- ...n_embeddable_panel_editor_empty_prompt.tsx | 66 ++++++ ...avigation_embeddable_panel_editor_link.tsx | 102 +++++++-- .../external_link/external_link_component.tsx | 18 +- .../external_link_destination_picker.tsx | 4 +- .../external_link/external_link_strings.ts | 6 +- .../navigation_embeddable_component.scss | 58 +++++ .../navigation_embeddable_component.tsx | 64 ++++-- .../navigation_embeddable_strings.ts | 58 ++--- .../public/editor/open_editor_flyout.tsx | 27 ++- .../public/editor/open_link_editor_flyout.tsx | 2 +- .../navigation_embeddable_factory.ts | 12 +- .../public/embeddable/types.ts | 40 +++- .../navigation_embeddable_storage.ts | 2 +- .../saved_objects/navigation_embeddable.ts | 1 + 38 files changed, 802 insertions(+), 344 deletions(-) create mode 100644 src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_cache.ts rename src/plugins/navigation_embeddable/public/components/{navigation_embeddable.scss => editor/navigation_embeddable_editor.scss} (75%) rename src/plugins/navigation_embeddable/public/components/{ => editor}/navigation_embeddable_link_editor.tsx (92%) rename src/plugins/navigation_embeddable/public/components/{ => editor}/navigation_embeddable_panel_editor.tsx (57%) create mode 100644 src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx rename src/plugins/navigation_embeddable/public/components/{ => editor}/navigation_embeddable_panel_editor_link.tsx (56%) create mode 100644 src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.scss diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 169221d659e53..101c986a30a56 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1055,6 +1055,7 @@ } }, "navigation_embeddable": { + "dynamic": false, "properties": { "id": { "type": "text" diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index 572d1b9d0f11a..1764f55a176e7 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -65,8 +65,14 @@ export const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2; export const CHANGE_CHECK_DEBOUNCE = 100; +// ------------------------------------------------------------------ +// Content Management +// ------------------------------------------------------------------ export { CONTENT_ID as DASHBOARD_CONTENT_ID } from '../common/content_management/constants'; +export const DASHBOARD_CACHE_SIZE = 20; // only store a max of 20 dashboards +export const DASHBOARD_CACHE_TTL = 1000 * 60 * 5; // time to live = 5 minutes + // ------------------------------------------------------------------ // Default State // ------------------------------------------------------------------ diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts index 9bb54da53653d..b4915ff67d0ba 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts @@ -44,6 +44,7 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen hits, }); }), + findById: jest.fn(), findByIds: jest.fn().mockImplementation(() => Promise.resolve([ { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_cache.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_cache.ts new file mode 100644 index 0000000000000..20b9e5a9cb2a7 --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_cache.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import LRUCache from 'lru-cache'; +import { DashboardCrudTypes } from '../../../common/content_management'; +import { DASHBOARD_CACHE_SIZE, DASHBOARD_CACHE_TTL } from '../../dashboard_constants'; + +export class DashboardContentManagementCache { + private cache: LRUCache; + + constructor() { + this.cache = new LRUCache({ + max: DASHBOARD_CACHE_SIZE, + maxAge: DASHBOARD_CACHE_TTL, + }); + } + + /** Fetch the dashboard with `id` from the cache */ + public fetchDashboard(id: string) { + return this.cache.get(id); + } + + /** Add the fetched dashboard to the cache */ + public addDashboard({ item: dashboard, meta }: DashboardCrudTypes['GetOut']) { + this.cache.set(dashboard.id, { + meta, + item: dashboard, + }); + } + + /** Delete the dashboard with `id` from the cache */ + public deleteDashboard(id: string) { + this.cache.del(id); + } +} diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts index f16bd4442f2c1..69ac2488ff47b 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts @@ -13,6 +13,7 @@ import { checkForDuplicateDashboardTitle } from './lib/check_for_duplicate_dashb import { searchDashboards, + findDashboardById, findDashboardsByIds, findDashboardIdByTitle, } from './lib/find_dashboards'; @@ -23,6 +24,7 @@ import type { } from './types'; import { loadDashboardState } from './lib/load_dashboard_state'; import { deleteDashboards } from './lib/delete_dashboards'; +import { DashboardContentManagementCache } from './dashboard_content_management_cache'; export type DashboardContentManagementServiceFactory = KibanaPluginServiceFactory< DashboardContentManagementService, @@ -30,6 +32,8 @@ export type DashboardContentManagementServiceFactory = KibanaPluginServiceFactor DashboardContentManagementRequiredServices >; +export const dashboardContentManagementCache = new DashboardContentManagementCache(); + export const dashboardContentManagementServiceFactory: DashboardContentManagementServiceFactory = ( { startPlugins: { contentManagement } }, requiredServices @@ -74,6 +78,7 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen search, size, }), + findById: (id) => findDashboardById(contentManagement, id), findByIds: (ids) => findDashboardsByIds(contentManagement, ids), findByTitle: (title) => findDashboardIdByTitle(contentManagement, title), }, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts index e18841eacfcfd..cf861a02b45e9 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts @@ -9,20 +9,22 @@ import { DashboardStartDependencies } from '../../../plugin'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; import { DashboardCrudTypes } from '../../../../common/content_management'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; export const deleteDashboards = async ( ids: string[], contentManagement: DashboardStartDependencies['contentManagement'] ) => { - const deletePromises = ids.map((id) => - contentManagement.client.delete< + const deletePromises = ids.map((id) => { + dashboardContentManagementCache.deleteDashboard(id); + return contentManagement.client.delete< DashboardCrudTypes['DeleteIn'], DashboardCrudTypes['DeleteOut'] >({ contentTypeId: DASHBOARD_CONTENT_ID, id, - }) - ); + }); + }); await Promise.all(deletePromises); }; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts index 49ffee54d536b..85f0cf4f53394 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts @@ -15,6 +15,7 @@ import { } from '../../../../common/content_management'; import { DashboardStartDependencies } from '../../../plugin'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; export interface SearchDashboardsArgs { contentManagement: DashboardStartDependencies['contentManagement']; @@ -67,23 +68,41 @@ export type FindDashboardsByIdResponse = { id: string } & ( | { status: 'error'; error: SavedObjectError } ); -export async function findDashboardsByIds( +export async function findDashboardById( contentManagement: DashboardStartDependencies['contentManagement'], - ids: string[] -): Promise { - const findPromises = ids.map((id) => - contentManagement.client.get({ + id: string +): Promise { + /** If the dashboard exists in the cache, then return the result from that */ + const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id); + if (cachedDashboard) { + return { + id, + status: 'success', + attributes: cachedDashboard.item.attributes, + }; + } + /** Otherwise, fetch the dashboard from the content management client, add it to the cache, and return the result */ + const response = await contentManagement.client + .get({ contentTypeId: DASHBOARD_CONTENT_ID, id, }) - ); - const results = await Promise.all(findPromises); + .then((result) => { + dashboardContentManagementCache.addDashboard(result); + return { id, status: 'success', attributes: result.item.attributes }; + }) + .catch((e) => ({ status: 'error', error: e.body, id })); - return results.map((result) => { - if (result.item.error) return { status: 'error', error: result.item.error, id: result.item.id }; - const { attributes, id } = result.item; - return { id, status: 'success', attributes }; - }); + return response as FindDashboardsByIdResponse; +} + +export async function findDashboardsByIds( + contentManagement: DashboardStartDependencies['contentManagement'], + ids: string[] +): Promise { + const findPromises = ids.map((id) => findDashboardById(contentManagement, id)); + const results = await Promise.all(findPromises); + return results as FindDashboardsByIdResponse[]; } export async function findDashboardIdByTitle( diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts index 538162a8eacbc..835dab964779d 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts @@ -23,6 +23,7 @@ import { import { DashboardCrudTypes } from '../../../../common/content_management'; import type { LoadDashboardFromSavedObjectProps, LoadDashboardReturn } from '../types'; import { DASHBOARD_CONTENT_ID, DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; export function migrateLegacyQuery(query: Query | { [key: string]: any } | string): Query { // Lucene was the only option before, so language-less queries are all lucene @@ -58,14 +59,28 @@ export const loadDashboardState = async ({ /** * Load the saved object from Content Management */ - const { item: rawDashboardContent, meta: resolveMeta } = await contentManagement.client - .get({ - contentTypeId: DASHBOARD_CONTENT_ID, - id, - }) - .catch((e) => { - throw new SavedObjectNotFound(DASHBOARD_CONTENT_ID, id); - }); + let rawDashboardContent; + let resolveMeta; + + const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id); + if (cachedDashboard) { + /** If the dashboard exists in the cache, use the cached version to load the dashboard */ + ({ item: rawDashboardContent, meta: resolveMeta } = cachedDashboard); + } else { + /** Otherwise, fetch and load the dashboard from the content management client, and add it to the cache */ + const result = await contentManagement.client + .get({ + contentTypeId: DASHBOARD_CONTENT_ID, + id, + }) + .catch((e) => { + throw new SavedObjectNotFound(DASHBOARD_CONTENT_ID, id); + }); + + dashboardContentManagementCache.addDashboard(result); + ({ item: rawDashboardContent, meta: resolveMeta } = result); + } + if (!rawDashboardContent || !rawDashboardContent.version) { return { dashboardInput: newDashboardState, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts index 60d1a0f8972e0..aef2d01f44e52 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts @@ -31,6 +31,7 @@ import { DashboardStartDependencies } from '../../../plugin'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; import { DashboardCrudTypes, DashboardAttributes } from '../../../../common/content_management'; import { dashboardSaveToastStrings } from '../../../dashboard_container/_dashboard_container_strings'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; export const serializeControlGroupInput = ( controlGroupInput: DashboardContainerInput['controlGroupInput'] @@ -200,6 +201,8 @@ export const saveDashboardState = async ({ if (newId !== lastSavedId) { dashboardSessionStorage.clearState(lastSavedId); return { redirectRequired: true, id: newId }; + } else { + dashboardContentManagementCache.deleteDashboard(newId); // something changed in an existing dashboard, so delete it from the cache so that it can be re-fetched } } return { id: newId }; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts index 858d5800961b5..35874d3df1fd1 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts @@ -92,6 +92,7 @@ export interface FindDashboardsService { 'hasReference' | 'hasNoReference' | 'search' | 'size' | 'options' > ) => Promise; + findById: (id: string) => Promise; findByIds: (ids: string[]) => Promise; findByTitle: (title: string) => Promise<{ id: string } | undefined>; } diff --git a/src/plugins/navigation_embeddable/common/constants.ts b/src/plugins/navigation_embeddable/common/constants.ts index 9731275e04f14..d3b25a888a084 100644 --- a/src/plugins/navigation_embeddable/common/constants.ts +++ b/src/plugins/navigation_embeddable/common/constants.ts @@ -17,3 +17,10 @@ export const APP_ICON = 'link'; export const APP_NAME = i18n.translate('navigationEmbeddable.visTypeAlias.title', { defaultMessage: 'Links', }); + +export const EMBEDDABLE_DISPLAY_NAME = i18n.translate( + 'navigationEmbeddable.embeddableDisplayName', + { + defaultMessage: 'links', + } +); diff --git a/src/plugins/navigation_embeddable/common/content_management/index.ts b/src/plugins/navigation_embeddable/common/content_management/index.ts index 282ba879c17fc..7b26870c7ce53 100644 --- a/src/plugins/navigation_embeddable/common/content_management/index.ts +++ b/src/plugins/navigation_embeddable/common/content_management/index.ts @@ -11,13 +11,19 @@ export { LATEST_VERSION, CONTENT_ID } from '../constants'; export type { NavigationEmbeddableContentType } from '../types'; export type { - NavigationEmbeddableCrudTypes, - NavigationEmbeddableAttributes, - NavigationEmbeddableItem, NavigationLinkType, + NavigationLayoutType, NavigationEmbeddableLink, + NavigationEmbeddableItem, + NavigationEmbeddableCrudTypes, + NavigationEmbeddableAttributes, } from './latest'; -export { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from './latest'; +export { + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + NAV_VERTICAL_LAYOUT, + NAV_HORIZONTAL_LAYOUT, +} from './latest'; export * as NavigationEmbeddableV1 from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts index 5494a193ba7b5..3c9c7a1bb759c 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts @@ -10,12 +10,13 @@ import { schema } from '@kbn/config-schema'; import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning'; import { savedObjectSchema, - objectTypeToGetResultSchema, - createOptionsSchemas, - updateOptionsSchema, createResultSchema, + updateOptionsSchema, + createOptionsSchemas, + objectTypeToGetResultSchema, } from '@kbn/content-management-utils'; import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '.'; +import { NAV_HORIZONTAL_LAYOUT, NAV_VERTICAL_LAYOUT } from './constants'; const navigationEmbeddableLinkSchema = schema.object({ id: schema.string(), @@ -30,6 +31,9 @@ const navigationEmbeddableAttributesSchema = schema.object( title: schema.string(), description: schema.maybe(schema.string()), links: schema.maybe(schema.arrayOf(navigationEmbeddableLinkSchema)), + layout: schema.maybe( + schema.oneOf([schema.literal(NAV_HORIZONTAL_LAYOUT), schema.literal(NAV_VERTICAL_LAYOUT)]) + ), }, { unknowns: 'forbid' } ); diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts b/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts index 00f40932638fe..70f1af5c0f69d 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts @@ -7,11 +7,13 @@ */ /** - * Dashboard to dashboard links + * Link types */ export const DASHBOARD_LINK_TYPE = 'dashboardLink'; +export const EXTERNAL_LINK_TYPE = 'externalLink'; /** - * External URL links + * Layout options */ -export const EXTERNAL_LINK_TYPE = 'externalLink'; +export const NAV_HORIZONTAL_LAYOUT = 'horizontal'; +export const NAV_VERTICAL_LAYOUT = 'vertical'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts index bedc5a6ff2f08..efda7e1cf696c 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts @@ -11,7 +11,13 @@ export type { NavigationEmbeddableCrudTypes, NavigationEmbeddableAttributes, NavigationEmbeddableLink, + NavigationLayoutType, NavigationLinkType, } from './types'; export type NavigationEmbeddableItem = NavigationEmbeddableCrudTypes['Item']; -export { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from './constants'; +export { + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + NAV_VERTICAL_LAYOUT, + NAV_HORIZONTAL_LAYOUT, +} from './constants'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts index 0d1a87a17d148..bb5c6c10c584b 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts @@ -12,7 +12,12 @@ import type { SavedObjectUpdateOptions, } from '@kbn/content-management-utils'; import { NavigationEmbeddableContentType } from '../../types'; -import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from './constants'; +import { + DASHBOARD_LINK_TYPE, + EXTERNAL_LINK_TYPE, + NAV_HORIZONTAL_LAYOUT, + NAV_VERTICAL_LAYOUT, +} from './constants'; export type NavigationEmbeddableCrudTypes = ContentManagementCrudTypes< NavigationEmbeddableContentType, @@ -38,9 +43,12 @@ export interface NavigationEmbeddableLink { order: number; } +export type NavigationLayoutType = typeof NAV_HORIZONTAL_LAYOUT | typeof NAV_VERTICAL_LAYOUT; + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type NavigationEmbeddableAttributes = { title: string; description?: string; links?: NavigationEmbeddableLink[]; + layout?: NavigationLayoutType; }; diff --git a/src/plugins/navigation_embeddable/public/_mixins.scss b/src/plugins/navigation_embeddable/public/_mixins.scss index f327bc1fe73d7..cc9b7a5168d80 100644 --- a/src/plugins/navigation_embeddable/public/_mixins.scss +++ b/src/plugins/navigation_embeddable/public/_mixins.scss @@ -26,17 +26,13 @@ @mixin euiFlyout { @include kibanaFullBodyHeight(); - border-left: $euiBorderThin; position: fixed; - z-index: $euiZFlyout; - background: $euiColorEmptyShade; display: flex; - flex-direction: column; - align-items: stretch; inline-size: 50vw; - - @media only screen and (max-width: 767px) { - inline-size: $euiSizeXL * 13; // 424px - max-inline-size: 90vw; - } + z-index: $euiZFlyout; + align-items: stretch; + flex-direction: column; + border-left: $euiBorderThin; + background: $euiColorEmptyShade; + min-width: ($euiSizeXL * 13) + $euiSizeS; // 424px } \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx index 60b88a740c14d..8259d98cce4b6 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx @@ -6,52 +6,127 @@ * Side Public License, v 1. */ -import React from 'react'; +import classNames from 'classnames'; import useAsync from 'react-use/lib/useAsync'; +import React, { useMemo, useState } from 'react'; -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonEmpty, EuiListGroupItem, EuiToolTip } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { NavigationLinkInfo } from '../../embeddable/types'; +import { + NavigationEmbeddableLink, + NavigationLayoutType, + NAV_VERTICAL_LAYOUT, +} from '../../../common/content_management'; import { fetchDashboard } from './dashboard_link_tools'; +import { DashboardLinkStrings } from './dashboard_link_strings'; import { useNavigationEmbeddable } from '../../embeddable/navigation_embeddable'; -import { DASHBOARD_LINK_TYPE, NavigationEmbeddableLink } from '../../../common/content_management'; -export const DashboardLinkComponent = ({ link }: { link: NavigationEmbeddableLink }) => { +export const DashboardLinkComponent = ({ + link, + layout, +}: { + link: NavigationEmbeddableLink; + layout: NavigationLayoutType; +}) => { const navEmbeddable = useNavigationEmbeddable(); + const [error, setError] = useState(); const dashboardContainer = navEmbeddable.parent as DashboardContainer; const parentDashboardTitle = dashboardContainer.select((state) => state.explicitInput.title); + const parentDashboardDescription = dashboardContainer.select( + (state) => state.explicitInput.description + ); + const parentDashboardId = dashboardContainer.select((state) => state.componentState.lastSavedId); const { loading: loadingDestinationDashboard, value: destinationDashboard } = useAsync(async () => { - if (!link.label && link.id !== parentDashboardId) { + if (link.id !== parentDashboardId) { /** - * only fetch the dashboard if **absolutely** necessary; i.e. only if the dashboard link doesn't have - * some custom label, and if it's not the current dashboard (if it is, use `dashboardContainer` instead) + * only fetch the dashboard if it's not the current dashboard - if it is the current dashboard, + * use `dashboardContainer` and its corresponding state (title, description, etc.) instead. */ - return await fetchDashboard(link.destination); + const dashboard = await fetchDashboard(link.destination) + .then((result) => { + setError(undefined); + return result; + }) + .catch((e) => setError(e)); + return dashboard; } }, [link, parentDashboardId]); - return ( - {}, // TODO: As part of https://github.com/elastic/kibana/issues/154381, connect to drilldown - })} - > - {link.label || - (link.destination === parentDashboardId - ? parentDashboardTitle - : destinationDashboard?.attributes.title)} - + const [dashboardTitle, dashboardDescription] = useMemo(() => { + return link.destination === parentDashboardId + ? [parentDashboardTitle, parentDashboardDescription] + : [destinationDashboard?.attributes.title, destinationDashboard?.attributes.description]; + }, [ + link.destination, + parentDashboardId, + parentDashboardTitle, + destinationDashboard, + parentDashboardDescription, + ]); + + const linkLabel = useMemo(() => { + return link.label || (dashboardTitle ?? DashboardLinkStrings.getDashboardErrorLabel()); + }, [link, dashboardTitle]); + + const { tooltipTitle, tooltipMessage } = useMemo(() => { + if (error) { + return { + tooltipTitle: DashboardLinkStrings.getDashboardErrorLabel(), + tooltipMessage: error.message, + }; + } + return { + tooltipTitle: Boolean(dashboardDescription) ? dashboardTitle : undefined, + tooltipMessage: dashboardDescription || dashboardTitle, + }; + }, [error, dashboardTitle, dashboardDescription]); + + return loadingDestinationDashboard ? ( + + ) : ( + { + // TODO: As part of https://github.com/elastic/kibana/issues/154381, connect to drilldown + } + } + label={ + + {/* Setting `title=""` so that the native browser tooltip is disabled */} +
+ {linkLabel} +
+
+ } + /> ); }; diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx index 7156449be366d..6bc62ef0912f4 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx @@ -22,8 +22,8 @@ import { import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { DashboardItem } from '../../embeddable/types'; -import { memoizedFetchDashboard, memoizedFetchDashboards } from './dashboard_link_tools'; -import { DashboardLinkEmbeddableStrings } from './dashboard_link_strings'; +import { DashboardLinkStrings } from './dashboard_link_strings'; +import { fetchDashboard, fetchDashboards } from './dashboard_link_tools'; type DashboardComboBoxOption = EuiComboBoxOptionOption; @@ -53,14 +53,23 @@ export const DashboardLinkDestinationPicker = ({ useMount(async () => { if (initialSelection) { - const dashboard = await memoizedFetchDashboard(initialSelection); - onDestinationPicked(dashboard); - setSelectedOption([getDashboardItem(dashboard)]); + const dashboard = await fetchDashboard(initialSelection).catch(() => { + /** + * Swallow the error that is thrown, since this just means the selected dashboard was deleted and + * so we should treat this the same as "no previous selection." + */ + }); + if (dashboard) { + onDestinationPicked(dashboard); + setSelectedOption([getDashboardItem(dashboard)]); + } else { + onDestinationPicked(undefined); + } } }); const { loading: loadingDashboardList, value: dashboardList } = useAsync(async () => { - const dashboards = await memoizedFetchDashboards({ + const dashboards = await fetchDashboards({ search: searchString, parentDashboardId, selectedDashboardId: initialSelection, @@ -86,7 +95,7 @@ export const DashboardLinkDestinationPicker = ({ {dashboardId === parentDashboardId && ( - {DashboardLinkEmbeddableStrings.getCurrentDashboardLabel()} + {DashboardLinkStrings.getCurrentDashboardLabel()} )} @@ -108,8 +117,8 @@ export const DashboardLinkDestinationPicker = ({ fullWidth className={'navEmbeddableDashboardPicker'} isLoading={loadingDashboardList} - aria-label={DashboardLinkEmbeddableStrings.getDashboardPickerAriaLabel()} - placeholder={DashboardLinkEmbeddableStrings.getDashboardPickerPlaceholder()} + aria-label={DashboardLinkStrings.getDashboardPickerAriaLabel()} + placeholder={DashboardLinkStrings.getDashboardPickerPlaceholder()} singleSelection={{ asPlainText: true }} options={dashboardList} onSearchChange={(searchValue) => { diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts index c763b0bd88e4e..ebda3bfa3763b 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts @@ -8,7 +8,11 @@ import { i18n } from '@kbn/i18n'; -export const DashboardLinkEmbeddableStrings = { +export const DashboardLinkStrings = { + getType: () => + i18n.translate('navigationEmbeddable.dashboardLink.type', { + defaultMessage: 'Dashboard link', + }), getDisplayName: () => i18n.translate('navigationEmbeddable.dashboardLink.displayName', { defaultMessage: 'Dashboard', @@ -29,4 +33,12 @@ export const DashboardLinkEmbeddableStrings = { i18n.translate('navigationEmbeddable.dashboardLink.editor.currentDashboardLabel', { defaultMessage: 'Current', }), + getLoadingDashboardLabel: () => + i18n.translate('navigationEmbeddable.dashboardLink.editor.loadingDashboardLabel', { + defaultMessage: 'Loading...', + }), + getDashboardErrorLabel: () => + i18n.translate('navigationEmbeddable.dashboardLink.editor.dashboardErrorLabel', { + defaultMessage: 'Error fetching dashboard', + }), }; diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx index 9590df2bd6c0d..d6f3e502d9c54 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { isEmpty, memoize, filter } from 'lodash'; +import { isEmpty, filter } from 'lodash'; import { DashboardItem } from '../../embeddable/types'; import { dashboardServices } from '../../services/kibana_services'; @@ -19,27 +19,13 @@ import { dashboardServices } from '../../services/kibana_services'; export const fetchDashboard = async (dashboardId: string): Promise => { const findDashboardsService = await dashboardServices.findDashboardsService(); - const response = (await findDashboardsService.findByIds([dashboardId]))[0]; + const response = await findDashboardsService.findById(dashboardId); if (response.status === 'error') { - throw new Error('failure'); // TODO: better error handling + throw new Error(response.error.message); } return response; }; -/** - * Memoized fetch dashboard will only refetch the dashboard information if the given `dashboardId` changed between - * calls; otherwise, it will use the cached dashboard, which may not take into account changes to the dashboard's title - * description, etc. Be mindful when choosing the memoized version. - */ -export const memoizedFetchDashboard = memoize( - async (dashboardId: string) => { - return await fetchDashboard(dashboardId); - }, - (dashboardId) => { - return dashboardId; - } -); - /** * ---------------------------------- * Fetch lists of dashboards @@ -53,7 +39,7 @@ interface FetchDashboardsProps { selectedDashboardId?: string; } -const fetchDashboards = async ({ +export const fetchDashboards = async ({ search = '', size = 10, parentDashboardId, @@ -81,7 +67,13 @@ const fetchDashboards = async ({ } if (selectedDashboardId && selectedDashboardId !== parentDashboardId) { - dashboardList.unshift(await fetchDashboard(selectedDashboardId)); + const selectedDashboard = await fetchDashboard(selectedDashboardId).catch(() => { + /** + * Swallow the error thrown, since this just means the selected dashboard was deleted and therefore + * it should not be added to the top of the dashboard list + */ + }); + if (selectedDashboard) dashboardList.unshift(await fetchDashboard(selectedDashboardId)); } } @@ -92,17 +84,3 @@ const fetchDashboards = async ({ return simplifiedDashboardList; }; - -export const memoizedFetchDashboards = memoize( - async ({ search, size, parentDashboardId, selectedDashboardId }: FetchDashboardsProps) => { - return await fetchDashboards({ - search, - size, - parentDashboardId, - selectedDashboardId, - }); - }, - ({ search, size, parentDashboardId, selectedDashboardId }) => { - return [search, size, parentDashboardId, selectedDashboardId].join('|'); - } -); diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_editor.scss similarity index 75% rename from src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss rename to src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_editor.scss index e7a6e5a1890a0..5a84104ea7c42 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss +++ b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_editor.scss @@ -1,4 +1,4 @@ -@import '../mixins'; +@import '../../mixins'; .navEmbeddablePanelEditor { max-inline-size: $euiSizeXXL * 18; // 40px * 18 = 720px @@ -39,19 +39,26 @@ } } -.navEmbeddableLinkText { - flex: 1; - min-width: 0; +.navEmbeddableLinkPanel { + padding: $euiSizeXS $euiSizeS; + color: $euiTextColor; - .wrapText { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + .navEmbeddableLinkText { + flex: 1; + min-width: 0; } -} -.navEmbeddableLinkPanel { - padding: $euiSizeXS $euiSizeS; + &.linkError { + border: 1px solid transparentize($euiColorWarningText, .7); + + .navEmbeddableLinkText { + color: $euiColorWarningText; + } + + .navEmbeddableLinkText--noLabel { + font-style: italic; + } + } .navEmbeddable_hoverActions { opacity: 0; @@ -65,4 +72,8 @@ visibility: visible; } } +} + +.navEmbeddableDroppableLinksArea { + margin: 0 (-$euiSizeXS); } \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_link_editor.tsx similarity index 92% rename from src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx rename to src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_link_editor.tsx index def291c63bcac..f02e37806e396 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_link_editor.tsx @@ -28,18 +28,17 @@ import { } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { NavigationLinkInfo } from '../embeddable/types'; import { NavigationLinkType, EXTERNAL_LINK_TYPE, DASHBOARD_LINK_TYPE, NavigationEmbeddableLink, -} from '../../common/content_management'; -import { DashboardItem } from '../embeddable/types'; -import { NavEmbeddableStrings } from './navigation_embeddable_strings'; -import { NavigationEmbeddableUnorderedLink } from '../editor/open_link_editor_flyout'; -import { ExternalLinkDestinationPicker } from './external_link/external_link_destination_picker'; -import { DashboardLinkDestinationPicker } from './dashboard_link/dashboard_link_destination_picker'; +} from '../../../common/content_management'; +import { NavEmbeddableStrings } from '../navigation_embeddable_strings'; +import { DashboardItem, NavigationLinkInfo } from '../../embeddable/types'; +import { NavigationEmbeddableUnorderedLink } from '../../editor/open_link_editor_flyout'; +import { ExternalLinkDestinationPicker } from '../external_link/external_link_destination_picker'; +import { DashboardLinkDestinationPicker } from '../dashboard_link/dashboard_link_destination_picker'; export const NavigationEmbeddableLinkEditor = ({ link, @@ -169,7 +168,7 @@ export const NavigationEmbeddableLinkEditor = ({ (linkDestination ? defaultLinkLabel : '') || NavEmbeddableStrings.editor.linkEditor.getLinkTextPlaceholder() } - value={linkDestination ? currentLinkLabel : ''} + value={currentLinkLabel} onChange={(e) => setCurrentLinkLabel(e.target.value)} /> diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.tsx similarity index 57% rename from src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx rename to src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.tsx index 97a5a86a9d653..892b2f777d3c5 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.tsx @@ -6,16 +6,11 @@ * Side Public License, v 1. */ -import useObservable from 'react-use/lib/useObservable'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { - EuiText, EuiForm, - EuiImage, EuiTitle, - EuiPanel, - EuiSpacer, EuiButton, EuiFormRow, EuiFlexItem, @@ -23,50 +18,75 @@ import { EuiDroppable, EuiDraggable, EuiFlyoutBody, - EuiEmptyPrompt, EuiButtonEmpty, + EuiButtonGroup, EuiFlyoutFooter, EuiFlyoutHeader, EuiDragDropContext, euiDragDropReorder, - EuiToolTip, + EuiButtonGroupOptionProps, + EuiSwitch, } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { coreServices } from '../services/kibana_services'; -import { NavigationEmbeddableLink } from '../../common/content_management'; -import { NavEmbeddableStrings } from './navigation_embeddable_strings'; - -import { openLinkEditorFlyout } from '../editor/open_link_editor_flyout'; -import { memoizedGetOrderedLinkList } from '../editor/navigation_embeddable_editor_tools'; +import { NavigationLayoutInfo } from '../../embeddable/types'; +import { + NavigationEmbeddableLink, + NavigationLayoutType, + NAV_HORIZONTAL_LAYOUT, + NAV_VERTICAL_LAYOUT, +} from '../../../common/content_management'; +import { coreServices } from '../../services/kibana_services'; +import { NavEmbeddableStrings } from '../navigation_embeddable_strings'; +import { openLinkEditorFlyout } from '../../editor/open_link_editor_flyout'; +import { memoizedGetOrderedLinkList } from '../../editor/navigation_embeddable_editor_tools'; import { NavigationEmbeddablePanelEditorLink } from './navigation_embeddable_panel_editor_link'; -import { TooltipWrapper } from './tooltip_wrapper'; +import { NavigationEmbeddablePanelEditorEmptyPrompt } from './navigation_embeddable_panel_editor_empty_prompt'; + +import { TooltipWrapper } from '../tooltip_wrapper'; -import noLinksIllustrationDark from '../assets/empty_links_dark.svg'; -import noLinksIllustrationLight from '../assets/empty_links_light.svg'; -import './navigation_embeddable.scss'; +import './navigation_embeddable_editor.scss'; + +const layoutOptions: EuiButtonGroupOptionProps[] = [ + { + id: NAV_VERTICAL_LAYOUT, + label: NavigationLayoutInfo[NAV_VERTICAL_LAYOUT].displayName, + }, + { + id: NAV_HORIZONTAL_LAYOUT, + label: NavigationLayoutInfo[NAV_HORIZONTAL_LAYOUT].displayName, + }, +]; const NavigationEmbeddablePanelEditor = ({ onSaveToLibrary, onAddToDashboard, onClose, initialLinks, + initialLayout, parentDashboard, isByReference, }: { - onSaveToLibrary: (newLinks: NavigationEmbeddableLink[]) => Promise; - onAddToDashboard: (newLinks: NavigationEmbeddableLink[]) => void; + onSaveToLibrary: ( + newLinks: NavigationEmbeddableLink[], + newLayout: NavigationLayoutType + ) => Promise; + onAddToDashboard: (newLinks: NavigationEmbeddableLink[], newLayout: NavigationLayoutType) => void; onClose: () => void; initialLinks?: NavigationEmbeddableLink[]; + initialLayout?: NavigationLayoutType; parentDashboard?: DashboardContainer; isByReference: boolean; }) => { - const isDarkTheme = useObservable(coreServices.theme.theme$)?.darkMode; const toasts = coreServices.notifications.toasts; const editLinkFlyoutRef: React.RefObject = useMemo(() => React.createRef(), []); - const [orderedLinks, setOrderedLinks] = useState([]); + const [currentLayout, setCurrentLayout] = useState( + initialLayout ?? NAV_VERTICAL_LAYOUT + ); const [isSaving, setIsSaving] = useState(false); + const [orderedLinks, setOrderedLinks] = useState([]); + const [saveByReference, setSaveByReference] = useState(!initialLinks ? true : isByReference); const isEditingExisting = initialLinks || isByReference; @@ -117,6 +137,10 @@ const NavigationEmbeddablePanelEditor = ({ [editLinkFlyoutRef, orderedLinks, parentDashboard] ); + const hasZeroLinks = useMemo(() => { + return orderedLinks.length === 0; + }, [orderedLinks]); + const deleteLink = useCallback( (linkId: string) => { setOrderedLinks( @@ -142,43 +166,35 @@ const NavigationEmbeddablePanelEditor = ({
- + {hasZeroLinks ? ( + addOrEditLink()} /> + ) : ( <> - {orderedLinks.length === 0 ? ( - - - } - body={ - <> - - {NavEmbeddableStrings.editor.panelEditor.getEmptyLinksMessage()} - - - addOrEditLink()} iconType="plusInCircle"> - {NavEmbeddableStrings.editor.getAddButtonLabel()} - - - } - /> - - ) : ( - <> + + { + setCurrentLayout(id as NavigationLayoutType); + }} + legend={NavEmbeddableStrings.editor.panelEditor.getLayoutSettingsLegend()} + /> + + + {/* Needs to be surrounded by a div rather than a fragment so the EuiFormRow can respond + to the focus of the inner elements */} +
- + {orderedLinks.map((link, idx) => ( - addOrEditLink()}> + addOrEditLink()} + > {NavEmbeddableStrings.editor.getAddButtonLabel()} - - )} +
+
-
+ )}
@@ -213,48 +234,35 @@ const NavigationEmbeddablePanelEditor = ({ - - {!isByReference ? ( - + + {!initialLinks || !isByReference ? ( + - { - onAddToDashboard(orderedLinks); - }} - > - {initialLinks - ? NavEmbeddableStrings.editor.panelEditor.getApplyButtonLabel() - : NavEmbeddableStrings.editor.panelEditor.getAddToDashboardButtonLabel()} - + setSaveByReference(!saveByReference)} + /> ) : null} - {!initialLinks || isByReference ? ( - - - {initialLinks - ? NavEmbeddableStrings.editor.panelEditor.getUpdateLibraryItemButtonTooltip() - : NavEmbeddableStrings.editor.panelEditor.getSaveToLibraryButtonTooltip()} -

- } - > - { + + + { + if (saveByReference) { setIsSaving(true); - onSaveToLibrary(orderedLinks) + onSaveToLibrary(orderedLinks, currentLayout) .catch((e) => { toasts.addError(e, { title: @@ -264,15 +272,15 @@ const NavigationEmbeddablePanelEditor = ({ .finally(() => { setIsSaving(false); }); - }} - > - {initialLinks - ? NavEmbeddableStrings.editor.panelEditor.getUpdateLibraryItemButtonLabel() - : NavEmbeddableStrings.editor.panelEditor.getSaveToLibraryButtonLabel()} - -
-
- ) : null} + } else { + onAddToDashboard(orderedLinks, currentLayout); + } + }} + > + {NavEmbeddableStrings.editor.panelEditor.getSaveButtonLabel()} + + +
diff --git a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx new file mode 100644 index 0000000000000..71083dbf87449 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import useObservable from 'react-use/lib/useObservable'; + +import { + EuiText, + EuiImage, + EuiPanel, + EuiSpacer, + EuiButton, + EuiEmptyPrompt, + EuiFormRow, +} from '@elastic/eui'; + +import { coreServices } from '../../services/kibana_services'; +import { NavEmbeddableStrings } from '../navigation_embeddable_strings'; + +import noLinksIllustrationDark from '../../assets/empty_links_dark.svg'; +import noLinksIllustrationLight from '../../assets/empty_links_light.svg'; + +import './navigation_embeddable_editor.scss'; + +export const NavigationEmbeddablePanelEditorEmptyPrompt = ({ + addLink, +}: { + addLink: () => Promise; +}) => { + const isDarkTheme = useObservable(coreServices.theme.theme$)?.darkMode; + + return ( + + + + } + body={ + <> + + {NavEmbeddableStrings.editor.panelEditor.getEmptyLinksMessage()} + + + + {NavEmbeddableStrings.editor.getAddButtonLabel()} + + + } + /> + + + ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_link.tsx similarity index 56% rename from src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx rename to src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_link.tsx index 7c6d2b7268102..a886ac6f9eb8f 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx +++ b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_link.tsx @@ -6,25 +6,28 @@ * Side Public License, v 1. */ -import React from 'react'; +import classNames from 'classnames'; +import React, { useMemo, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { + EuiText, EuiIcon, EuiPanel, + EuiToolTip, EuiFlexItem, EuiFlexGroup, EuiButtonIcon, EuiSkeletonTitle, DraggableProvidedDragHandleProps, - EuiToolTip, } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { NavigationLinkInfo } from '../embeddable/types'; -import { DASHBOARD_LINK_TYPE, NavigationEmbeddableLink } from '../../common/content_management'; -import { fetchDashboard } from './dashboard_link/dashboard_link_tools'; -import { NavEmbeddableStrings } from './navigation_embeddable_strings'; +import { NavigationLinkInfo } from '../../embeddable/types'; +import { fetchDashboard } from '../dashboard_link/dashboard_link_tools'; +import { NavEmbeddableStrings } from '../navigation_embeddable_strings'; +import { DashboardLinkStrings } from '../dashboard_link/dashboard_link_strings'; +import { DASHBOARD_LINK_TYPE, NavigationEmbeddableLink } from '../../../common/content_management'; export const NavigationEmbeddablePanelEditorLink = ({ link, @@ -39,24 +42,85 @@ export const NavigationEmbeddablePanelEditorLink = ({ parentDashboard?: DashboardContainer; dragHandleProps?: DraggableProvidedDragHandleProps; }) => { + const [dashboardError, setDashboardError] = useState(); const parentDashboardTitle = parentDashboard?.select((state) => state.explicitInput.title); const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId); const { value: linkLabel, loading: linkLabelLoading } = useAsync(async () => { - let label = link.label; - if (link.type === DASHBOARD_LINK_TYPE && !label) { + if (link.type === DASHBOARD_LINK_TYPE) { if (parentDashboardId === link.destination) { - label = parentDashboardTitle; + return link.label || parentDashboardTitle; } else { - const dashboard = await fetchDashboard(link.destination); - label = dashboard.attributes.title; + const dashboard = await fetchDashboard(link.destination) + .then((result) => { + setDashboardError(undefined); + return result; + }) + .catch((error) => setDashboardError(error)); + return ( + link.label || + (dashboard ? dashboard.attributes.title : DashboardLinkStrings.getDashboardErrorLabel()) + ); } + } else { + return link.label || link.destination; } - return label || link.destination; }, [link]); + const LinkLabel = useMemo(() => { + const labelText = ( + + + + + + + + + {linkLabel} + + + + + ); + + return () => + dashboardError ? ( + + {labelText} + + ) : ( + labelText + ); + }, [linkLabel, linkLabelLoading, dashboardError, link.label, link.type]); + return ( - + - - - - -
{linkLabel}
-
+
+ diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx index 7b940ac027357..52442cd307b0b 100644 --- a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx @@ -8,14 +8,20 @@ import React from 'react'; -import { EuiButtonEmpty } from '@elastic/eui'; -import { NavigationLinkInfo } from '../../embeddable/types'; -import { EXTERNAL_LINK_TYPE, NavigationEmbeddableLink } from '../../../common/content_management'; +import { EuiListGroupItem } from '@elastic/eui'; +import { NavigationEmbeddableLink } from '../../../common/content_management'; export const ExternalLinkComponent = ({ link }: { link: NavigationEmbeddableLink }) => { return ( - - {link.label || link.destination} - + { + // TODO: As part of https://github.com/elastic/kibana/issues/154381, connect to drilldown + }} + /> ); }; diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx b/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx index 4119cc32f32aa..4019e4c843faf 100644 --- a/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx @@ -10,7 +10,7 @@ import useMount from 'react-use/lib/useMount'; import React, { useState } from 'react'; import { EuiFieldText } from '@elastic/eui'; -import { ExternalLinkEmbeddableStrings } from './external_link_strings'; +import { ExternalLinkStrings } from './external_link_strings'; // TODO: As part of https://github.com/elastic/kibana/issues/154381, replace this regex URL check with more robust url validation const isValidUrl = @@ -39,7 +39,7 @@ export const ExternalLinkDestinationPicker = ({
{ const url = e.target.value; diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts b/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts index 77d7b479706b6..e286019d4bc05 100644 --- a/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts @@ -8,7 +8,11 @@ import { i18n } from '@kbn/i18n'; -export const ExternalLinkEmbeddableStrings = { +export const ExternalLinkStrings = { + getType: () => + i18n.translate('navigationEmbeddable.externalLink.type', { + defaultMessage: 'External URL', + }), getDisplayName: () => i18n.translate('navigationEmbeddable.externalLink.displayName', { defaultMessage: 'URL', diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.scss b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.scss new file mode 100644 index 0000000000000..bbc51f041efec --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.scss @@ -0,0 +1,58 @@ +.navEmbeddableComponent { + + .navigationLink { + max-width: fit-content; // added this so that the error tooltip shows up **right beside** the link label + + &.dashboardLinkError { + &.dashboardLinkError--noLabel .euiListGroupItem__button { + font-style: italic; + } + + .dashboardLinkIcon { + margin-right: $euiSizeS; + } + } + + &.navigationLinkCurrent { + border-radius: 0; + .euiListGroupItem__text { + cursor: default; + color: $euiColorPrimary; + } + } + } + + .verticalLayoutWrapper { + gap: $euiSizeXS; + .navigationLink { + &.navigationLinkCurrent { + &::before { + content: ''; + position: absolute; + width: .5 * $euiSizeXS; + height: 75%; + background-color: $euiColorPrimary; + } + } + } + } + + .horizontalLayoutWrapper { + height: 100%; + display: flex; + flex-wrap: nowrap; + align-items: center; + flex-direction: row; + + .navigationLink { + &.navigationLinkCurrent { + padding: 0 $euiSizeS; + + .euiListGroupItem__text { + box-shadow: $euiColorPrimary 0 (-.5 * $euiSizeXS) inset; + padding-inline: 0; + } + } + } + } +} \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx index a45d6d8028676..544f5593541df 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx @@ -8,46 +8,66 @@ import React, { useMemo } from 'react'; -import { EuiPanel } from '@elastic/eui'; +import { EuiListGroup, EuiPanel } from '@elastic/eui'; -import { DASHBOARD_LINK_TYPE } from '../../common/content_management'; +import { NavigationEmbeddableByValueInput } from '../embeddable/types'; import { useNavigationEmbeddable } from '../embeddable/navigation_embeddable'; import { ExternalLinkComponent } from './external_link/external_link_component'; import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; import { memoizedGetOrderedLinkList } from '../editor/navigation_embeddable_editor_tools'; -import { NavigationEmbeddableByValueInput } from '../embeddable/types'; +import { + DASHBOARD_LINK_TYPE, + NAV_HORIZONTAL_LAYOUT, + NAV_VERTICAL_LAYOUT, +} from '../../common/content_management'; + +import './navigation_embeddable_component.scss'; export const NavigationEmbeddableComponent = () => { const navEmbeddable = useNavigationEmbeddable(); - const links = navEmbeddable.select( (state) => (state.explicitInput as NavigationEmbeddableByValueInput).attributes?.links ); + const layout = navEmbeddable.select( + (state) => (state.explicitInput as NavigationEmbeddableByValueInput).attributes?.layout + ); const orderedLinks = useMemo(() => { if (!links) return []; return memoizedGetOrderedLinkList(links); }, [links]); - /** TODO: Render this as a list **or** "tabs" as part of https://github.com/elastic/kibana/issues/154357 */ - return ( - - {orderedLinks.map((link) => { - return ( - - {link.type === DASHBOARD_LINK_TYPE ? ( - + const linkItems: { [id: string]: { id: string; content: JSX.Element } } = useMemo(() => { + return (links ?? []).reduce((prev, currentLink) => { + return { + ...prev, + [currentLink.id]: { + id: currentLink.id, + content: + currentLink.type === DASHBOARD_LINK_TYPE ? ( + ) : ( - - )} - - ); - })} + + ), + }, + }; + }, {}); + }, [links, layout]); + + return ( + + + {orderedLinks.map((link) => linkItems[link.id].content)} + ); }; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts index 20953c41dbe8c..d229058e95f60 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts @@ -31,6 +31,10 @@ export const NavEmbeddableStrings = { defaultMessage: 'Close', }), panelEditor: { + getLinksTitle: () => + i18n.translate('navigationEmbeddable.panelEditor.linksTitle', { + defaultMessage: 'Links', + }), getEmptyLinksMessage: () => i18n.translate('navigationEmbeddable.panelEditor.emptyLinksMessage', { defaultMessage: 'Use links to navigate to commonly used dashboards and websites.', @@ -47,47 +51,47 @@ export const NavEmbeddableStrings = { i18n.translate('navigationEmbeddable.panelEditor.editFlyoutTitle', { defaultMessage: 'Edit links panel', }), - getApplyButtonLabel: () => - i18n.translate('navigationEmbeddable.panelEditor.applyButtonLabel', { - defaultMessage: 'Apply', - }), - getAddToDashboardButtonLabel: () => - i18n.translate('navigationEmbeddable.panelEditor.addToDashboardButtonLabel', { - defaultMessage: 'Add to dashboard', - }), - getAddToDashboardButtonTooltip: () => - i18n.translate('navigationEmbeddable.panelEditor.addToDashboardButtonTooltip', { - defaultMessage: 'Add this links panel directly to this dashboard.', + getSaveButtonLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.saveButtonLabel', { + defaultMessage: 'Save', }), - getSaveToLibraryButtonLabel: () => - i18n.translate('navigationEmbeddable.panelEditor.saveToLibraryButtonLabel', { + getSaveToLibrarySwitchLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.saveToLibrarySwitchLabel', { defaultMessage: 'Save to library', }), - getSaveToLibraryButtonTooltip: () => - i18n.translate('navigationEmbeddable.panelEditor.saveToLibraryButtonTooltip', { + getSaveToLibrarySwitchTooltip: () => + i18n.translate('navigationEmbeddable.panelEditor.saveToLibrarySwitchTooltip', { defaultMessage: 'Save this links panel to the library so you can easily add it to other dashboards.', }), - getUpdateLibraryItemButtonLabel: () => - i18n.translate('navigationEmbeddable.panelEditor.updateLibraryItemButtonLabel', { - defaultMessage: 'Update library item', - }), - getUpdateLibraryItemButtonTooltip: () => - i18n.translate('navigationEmbeddable.panelEditor.updateLibraryItemButtonTooltip', { - defaultMessage: 'Editing this panel might affect other dashboards.', - }), getTitleInputLabel: () => i18n.translate('navigationEmbeddable.panelEditor.titleInputLabel', { defaultMessage: 'Title', }), - getLinkLoadingAriaLabel: () => - i18n.translate('navigationEmbeddable.linkEditor.linkLoadingAriaLabel', { - defaultMessage: 'Loading link', + getBrokenDashboardLinkAriaLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.brokenDashboardLinkAriaLabel', { + defaultMessage: 'Broken dashboard link', }), getDragHandleAriaLabel: () => - i18n.translate('navigationEmbeddable.editor.dragHandleAriaLabel', { + i18n.translate('navigationEmbeddable.panelEditor.dragHandleAriaLabel', { defaultMessage: 'Link drag handle', }), + getLayoutSettingsTitle: () => + i18n.translate('navigationEmbeddable.panelEditor.layoutSettingsTitle', { + defaultMessage: 'Layout', + }), + getLayoutSettingsLegend: () => + i18n.translate('navigationEmbeddable.panelEditor.layoutSettingsLegend', { + defaultMessage: 'Choose how to display your links.', + }), + getHorizontalLayoutLabel: () => + i18n.translate('navigationEmbeddable.editor.horizontalLayout', { + defaultMessage: 'Horizontal', + }), + getVerticalLayoutLabel: () => + i18n.translate('navigationEmbeddable.editor.verticalLayout', { + defaultMessage: 'Vertical', + }), getErrorDuringSaveToastTitle: () => i18n.translate('navigationEmbeddable.editor.unableToSaveToastTitle', { defaultMessage: 'Error saving Link panel', diff --git a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx index e156180d8dd4d..14e3974473d94 100644 --- a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx +++ b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { Subject } from 'rxjs'; -import { memoize } from 'lodash'; import { skip, take, takeUntil } from 'rxjs/operators'; import { withSuspense } from '@kbn/shared-ux-utility'; @@ -16,18 +15,17 @@ import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { coreServices } from '../services/kibana_services'; import { - NavigationEmbeddableByReferenceInput, NavigationEmbeddableInput, + NavigationEmbeddableByReferenceInput, } from '../embeddable/types'; -import { memoizedFetchDashboards } from '../components/dashboard_link/dashboard_link_tools'; -import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; -import { NavigationEmbeddableLink } from '../../common/content_management'; +import { coreServices } from '../services/kibana_services'; import { runSaveToLibrary } from '../content_management/save_to_library'; +import { NavigationEmbeddableLink, NavigationLayoutType } from '../../common/content_management'; +import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; const LazyNavigationEmbeddablePanelEditor = React.lazy( - () => import('../components/navigation_embeddable_panel_editor') + () => import('../components/editor/navigation_embeddable_panel_editor') ); const NavigationEmbeddablePanelEditor = withSuspense( @@ -51,10 +49,14 @@ export async function openEditorFlyout( return new Promise((resolve, reject) => { const closed$ = new Subject(); - const onSaveToLibrary = async (newLinks: NavigationEmbeddableLink[]) => { + const onSaveToLibrary = async ( + newLinks: NavigationEmbeddableLink[], + newLayout: NavigationLayoutType + ) => { const newAttributes = { ...attributes, links: newLinks, + layout: newLayout, }; const updatedInput = (initialInput as NavigationEmbeddableByReferenceInput).savedObjectId ? await attributeService.wrapAttributes(newAttributes, true, initialInput) @@ -67,12 +69,16 @@ export async function openEditorFlyout( editorFlyout.close(); }; - const onAddToDashboard = (newLinks: NavigationEmbeddableLink[]) => { + const onAddToDashboard = ( + newLinks: NavigationEmbeddableLink[], + newLayout: NavigationLayoutType + ) => { const newInput: NavigationEmbeddableInput = { ...initialInput, attributes: { ...attributes, links: newLinks, + layout: newLayout, }, }; resolve(newInput); @@ -98,6 +104,7 @@ export async function openEditorFlyout( toMountPoint( { - // we should always re-fetch the dashboards when the editor is opened; so, clear the cache on close - memoizedFetchDashboards.cache = new memoize.Cache(); closed$.next(true); }); }); diff --git a/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx index 1fee9bdd9b206..896c1717f554e 100644 --- a/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx +++ b/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx @@ -14,7 +14,7 @@ import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_conta import { coreServices } from '../services/kibana_services'; import { NavigationEmbeddableLink } from '../../common/content_management'; -import { NavigationEmbeddableLinkEditor } from '../components/navigation_embeddable_link_editor'; +import { NavigationEmbeddableLinkEditor } from '../components/editor/navigation_embeddable_link_editor'; export interface LinkEditorProps { link?: NavigationEmbeddableLink; diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts index 9711c81b64125..ecbaaf8ba6ee2 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts @@ -14,18 +14,19 @@ import { ErrorEmbeddable, } from '@kbn/embeddable-plugin/public'; import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; +import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; import { - NavigationEmbeddableByReferenceInput, NavigationEmbeddableByValueInput, + NavigationEmbeddableByReferenceInput, NavigationEmbeddableInput, } from './types'; import type { NavigationEmbeddable } from './navigation_embeddable'; -import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; +import { NAV_VERTICAL_LAYOUT } from '../../common/content_management'; +import { APP_ICON, APP_NAME, CONTENT_ID, EMBEDDABLE_DISPLAY_NAME } from '../../common'; import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; -import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; +import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; export type NavigationEmbeddableFactory = EmbeddableFactory; @@ -38,6 +39,7 @@ export interface NavigationEmbeddableCreationOptions { const getDefaultNavigationEmbeddableInput = (): Omit => ({ attributes: { title: '', + layout: NAV_VERTICAL_LAYOUT, }, disabledActions: [ACTION_ADD_PANEL, 'OPEN_FLYOUT_ADD_DRILLDOWN'], }); @@ -124,7 +126,7 @@ export class NavigationEmbeddableFactoryDefinition } public getDisplayName() { - return APP_NAME; + return EMBEDDABLE_DISPLAY_NAME; } public getIconType() { diff --git a/src/plugins/navigation_embeddable/public/embeddable/types.ts b/src/plugins/navigation_embeddable/public/embeddable/types.ts index 43d12f611df8b..04e79731b6a77 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/types.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/types.ts @@ -14,14 +14,29 @@ import { } from '@kbn/embeddable-plugin/public'; import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; -import { ExternalLinkEmbeddableStrings } from '../components/external_link/external_link_strings'; -import { DashboardLinkEmbeddableStrings } from '../components/dashboard_link/dashboard_link_strings'; import { - DASHBOARD_LINK_TYPE, - EXTERNAL_LINK_TYPE, NavigationLinkType, + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + NAV_VERTICAL_LAYOUT, + NavigationLayoutType, + NAV_HORIZONTAL_LAYOUT, NavigationEmbeddableAttributes, } from '../../common/content_management'; +import { DashboardLinkStrings } from '../components/dashboard_link/dashboard_link_strings'; +import { ExternalLinkStrings } from '../components/external_link/external_link_strings'; +import { NavEmbeddableStrings } from '../components/navigation_embeddable_strings'; + +export const NavigationLayoutInfo: { + [id in NavigationLayoutType]: { displayName: string }; +} = { + [NAV_HORIZONTAL_LAYOUT]: { + displayName: NavEmbeddableStrings.editor.panelEditor.getHorizontalLayoutLabel(), + }, + [NAV_VERTICAL_LAYOUT]: { + displayName: NavEmbeddableStrings.editor.panelEditor.getVerticalLayoutLabel(), + }, +}; export interface DashboardItem { id: string; @@ -29,17 +44,24 @@ export interface DashboardItem { } export const NavigationLinkInfo: { - [id in NavigationLinkType]: { icon: string; displayName: string; description: string }; + [id in NavigationLinkType]: { + icon: string; + type: string; + displayName: string; + description: string; + }; } = { [DASHBOARD_LINK_TYPE]: { icon: 'dashboardApp', - displayName: DashboardLinkEmbeddableStrings.getDisplayName(), - description: DashboardLinkEmbeddableStrings.getDescription(), + type: DashboardLinkStrings.getType(), + displayName: DashboardLinkStrings.getDisplayName(), + description: DashboardLinkStrings.getDescription(), }, [EXTERNAL_LINK_TYPE]: { icon: 'link', - displayName: ExternalLinkEmbeddableStrings.getDisplayName(), - description: ExternalLinkEmbeddableStrings.getDescription(), + type: ExternalLinkStrings.getType(), + displayName: ExternalLinkStrings.getDisplayName(), + description: ExternalLinkStrings.getDescription(), }, }; diff --git a/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts b/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts index 07318f62a3e10..db830dfad512d 100644 --- a/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts +++ b/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts @@ -17,7 +17,7 @@ export class NavigationEmbeddableStorage extends SOContentStorage Date: Wed, 16 Aug 2023 12:16:21 -0600 Subject: [PATCH 09/53] Add `EMBEDDABLE_DISPLAY_NAME` to constants --- src/plugins/navigation_embeddable/common/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/plugins/navigation_embeddable/common/index.ts b/src/plugins/navigation_embeddable/common/index.ts index 9cb4fc42124aa..0251a4d26782c 100644 --- a/src/plugins/navigation_embeddable/common/index.ts +++ b/src/plugins/navigation_embeddable/common/index.ts @@ -6,4 +6,10 @@ * Side Public License, v 1. */ -export { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from './constants'; +export { + APP_ICON, + APP_NAME, + CONTENT_ID, + LATEST_VERSION, + EMBEDDABLE_DISPLAY_NAME, +} from './constants'; From 6fa536cd71244f9bbf3345b91e11c118de3f3962 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 16 Aug 2023 13:03:35 -0600 Subject: [PATCH 10/53] Switch back to `APP_NAME` for now --- src/plugins/navigation_embeddable/common/constants.ts | 7 ------- src/plugins/navigation_embeddable/common/index.ts | 8 +------- .../public/embeddable/navigation_embeddable_factory.ts | 4 ++-- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/src/plugins/navigation_embeddable/common/constants.ts b/src/plugins/navigation_embeddable/common/constants.ts index d3b25a888a084..9731275e04f14 100644 --- a/src/plugins/navigation_embeddable/common/constants.ts +++ b/src/plugins/navigation_embeddable/common/constants.ts @@ -17,10 +17,3 @@ export const APP_ICON = 'link'; export const APP_NAME = i18n.translate('navigationEmbeddable.visTypeAlias.title', { defaultMessage: 'Links', }); - -export const EMBEDDABLE_DISPLAY_NAME = i18n.translate( - 'navigationEmbeddable.embeddableDisplayName', - { - defaultMessage: 'links', - } -); diff --git a/src/plugins/navigation_embeddable/common/index.ts b/src/plugins/navigation_embeddable/common/index.ts index 0251a4d26782c..9cb4fc42124aa 100644 --- a/src/plugins/navigation_embeddable/common/index.ts +++ b/src/plugins/navigation_embeddable/common/index.ts @@ -6,10 +6,4 @@ * Side Public License, v 1. */ -export { - APP_ICON, - APP_NAME, - CONTENT_ID, - LATEST_VERSION, - EMBEDDABLE_DISPLAY_NAME, -} from './constants'; +export { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from './constants'; diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts index ecbaaf8ba6ee2..d05ca560e350a 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts @@ -22,9 +22,9 @@ import { NavigationEmbeddableByReferenceInput, NavigationEmbeddableInput, } from './types'; +import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; import type { NavigationEmbeddable } from './navigation_embeddable'; import { NAV_VERTICAL_LAYOUT } from '../../common/content_management'; -import { APP_ICON, APP_NAME, CONTENT_ID, EMBEDDABLE_DISPLAY_NAME } from '../../common'; import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; @@ -126,7 +126,7 @@ export class NavigationEmbeddableFactoryDefinition } public getDisplayName() { - return EMBEDDABLE_DISPLAY_NAME; + return APP_NAME; } public getIconType() { From d51bcd88e50e746faadb3288c0233e4ad9b6dcd7 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Mon, 28 Aug 2023 11:23:33 -0400 Subject: [PATCH 11/53] [Dashboard Navigation] Store unwrapped attributes in componentState (#164887) Fixes https://github.com/elastic/kibana/issues/164322 Store the unwrapped attributes from by-reference links panels in `componentState`. Since the `explictInput` for a Links panel can be either by-reference or by-value, we need to be sure to share the unwrapped attributes (as in from a by-reference panel) to our component. --- .../navigation_embeddable_component.tsx | 11 ++------ .../embeddable/navigation_embeddable.tsx | 27 +++++++++++++------ .../navigation_embeddable_factory.ts | 22 +++------------ .../navigation_embeddable_reducers.ts | 8 ++++++ .../public/embeddable/types.ts | 4 +-- 5 files changed, 34 insertions(+), 38 deletions(-) diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx index 544f5593541df..c6423f0c529d2 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx @@ -7,10 +7,7 @@ */ import React, { useMemo } from 'react'; - import { EuiListGroup, EuiPanel } from '@elastic/eui'; - -import { NavigationEmbeddableByValueInput } from '../embeddable/types'; import { useNavigationEmbeddable } from '../embeddable/navigation_embeddable'; import { ExternalLinkComponent } from './external_link/external_link_component'; import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; @@ -25,12 +22,8 @@ import './navigation_embeddable_component.scss'; export const NavigationEmbeddableComponent = () => { const navEmbeddable = useNavigationEmbeddable(); - const links = navEmbeddable.select( - (state) => (state.explicitInput as NavigationEmbeddableByValueInput).attributes?.links - ); - const layout = navEmbeddable.select( - (state) => (state.explicitInput as NavigationEmbeddableByValueInput).attributes?.layout - ); + const links = navEmbeddable.select((state) => state.componentState.links); + const layout = navEmbeddable.select((state) => state.componentState.layout); const orderedLinks = useMemo(() => { if (!links) return []; diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx index 58a665488b825..a275ee26fe1fc 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx @@ -7,7 +7,8 @@ */ import React, { createContext, useContext } from 'react'; -import { Subscription } from 'rxjs'; +import { Subscription, distinctUntilChanged, skip } from 'rxjs'; +import deepEqual from 'fast-deep-equal'; import { AttributeService, @@ -92,7 +93,9 @@ export class NavigationEmbeddable >({ embeddable: this, reducers: navigationEmbeddableReducers, - initialComponentState: {}, + initialComponentState: { + title: '', + }, }); this.select = reduxEmbeddableTools.select; @@ -101,24 +104,31 @@ export class NavigationEmbeddable this.cleanupStateTools = reduxEmbeddableTools.cleanup; this.onStateChange = reduxEmbeddableTools.onStateChange; - this.initializeSavedLinks(initialInput) + this.initializeSavedLinks() .then(() => this.setInitializationFinished()) .catch((e: Error) => this.onFatalError(e)); + + // By-value panels should update the componentState when input changes + this.subscriptions.add( + this.getInput$() + .pipe(distinctUntilChanged(deepEqual), skip(1)) + .subscribe(async () => await this.initializeSavedLinks()) + ); } - private async initializeSavedLinks(input: NavigationEmbeddableInput) { - const { attributes } = await this.attributeService.unwrapAttributes(input); + private async initializeSavedLinks() { + const { attributes } = await this.attributeService.unwrapAttributes(this.getInput()); if (this.isDestroyed) return; // TODO handle metaInfo - this.updateInput({ attributes }); + this.dispatch.setAttributes(attributes); await this.initializeOutput(); } private async initializeOutput() { - const { attributes } = this.getInput() as NavigationEmbeddableByValueInput; + const attributes = this.getState().componentState; const { title, description } = this.getInput(); this.updateOutput({ defaultTitle: attributes.title, @@ -147,7 +157,8 @@ export class NavigationEmbeddable public async reload() { if (this.isDestroyed) return; - await this.initializeSavedLinks(this.getInput()); + // By-reference embeddable panels are reloaded when changed, so update the componentState + this.initializeSavedLinks(); this.render(); } diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts index d05ca560e350a..3226833f1f9c8 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts @@ -7,41 +7,25 @@ */ import { - ACTION_ADD_PANEL, EmbeddableFactory, EmbeddableFactoryDefinition, - EmbeddablePackageState, ErrorEmbeddable, } from '@kbn/embeddable-plugin/public'; import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { - NavigationEmbeddableByValueInput, - NavigationEmbeddableByReferenceInput, - NavigationEmbeddableInput, -} from './types'; +import { NavigationEmbeddableByReferenceInput, NavigationEmbeddableInput } from './types'; import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; import type { NavigationEmbeddable } from './navigation_embeddable'; -import { NAV_VERTICAL_LAYOUT } from '../../common/content_management'; import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; export type NavigationEmbeddableFactory = EmbeddableFactory; -export interface NavigationEmbeddableCreationOptions { - getInitialInput?: () => Partial; - getIncomingEmbeddable?: () => EmbeddablePackageState | undefined; -} - // TODO: Replace string 'OPEN_FLYOUT_ADD_DRILLDOWN' with constant as part of https://github.com/elastic/kibana/issues/154381 -const getDefaultNavigationEmbeddableInput = (): Omit => ({ - attributes: { - title: '', - layout: NAV_VERTICAL_LAYOUT, - }, - disabledActions: [ACTION_ADD_PANEL, 'OPEN_FLYOUT_ADD_DRILLDOWN'], +const getDefaultNavigationEmbeddableInput = (): Partial => ({ + disabledActions: ['OPEN_FLYOUT_ADD_DRILLDOWN'], }); export class NavigationEmbeddableFactoryDefinition diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts index 29a79c4f6154f..b671c395e061b 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts @@ -11,6 +11,7 @@ import { WritableDraft } from 'immer/dist/types/types-external'; import { PayloadAction } from '@reduxjs/toolkit'; import { NavigationEmbeddableReduxState } from './types'; +import { NavigationEmbeddableAttributes } from '../../common/content_management'; export const navigationEmbeddableReducers = { /** @@ -24,4 +25,11 @@ export const navigationEmbeddableReducers = { ) => { state.output.loading = action.payload; }, + + setAttributes: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.componentState = { ...action.payload }; + }, }; diff --git a/src/plugins/navigation_embeddable/public/embeddable/types.ts b/src/plugins/navigation_embeddable/public/embeddable/types.ts index 04e79731b6a77..8744dd613c4b5 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/types.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/types.ts @@ -82,10 +82,10 @@ export type NavigationEmbeddableOutput = EmbeddableOutput & { /** * Navigation embeddable redux state */ -// export interface NavigationEmbeddableComponentState {} // TODO: Uncomment this if we end up needing component state +export type NavigationEmbeddableComponentState = NavigationEmbeddableAttributes; export type NavigationEmbeddableReduxState = ReduxEmbeddableState< NavigationEmbeddableInput, NavigationEmbeddableOutput, - {} // We currently don't have any component state - TODO: Replace with `NavigationEmbeddableComponentState` if necessary + NavigationEmbeddableComponentState >; From 409a8f946dc40d1c31d8d3c923ccdcf0e0e37c08 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 31 Aug 2023 16:07:30 -0400 Subject: [PATCH 12/53] [Dashboard navigation] Inject/extract references (#164330) Fixes https://github.com/elastic/kibana/issues/154363 ## Summary Extracts and injects references to Dashboards in Link panels to allow sharing / importing to other Spaces. To test this: 1. Create an empty dashboard. 1. Create a Links panel on the dashboard and save to library. 1. Save the dashboard as one or more new dashboards. 1. Update the Links panel with links to the new dashboards. 1. Create two new Spaces, "space1" and "space2" with the default settings. 1. In Saved Objects, click the Actions button for your Links and choose "Copy to Spaces". 1. Copy the Links to "space1". 1. Switch to the "space1" space and verify all the dashboards using that Links panel were included. 1. In Saved Objects, select the Links saved object and export it including all related objects 1. Switch to "space2" and import the Links saved object from the file downloaded in the previous step. 1. Verify all the dashboards were also imported. 1. Inspect the saved object for the Links. The `references` array should contain objects for each dashboard. 1. The `attributes.links[].destination` property should be a string in the format of `links__dashboard`. This string should match one of the `references[].id`. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../lib/find_dashboards.ts | 1 + .../common/content_management/index.ts | 1 + .../content_management/v1/cm_services.ts | 24 ++- .../common/content_management/v1/constants.ts | 2 + .../common/content_management/v1/index.ts | 1 + .../common/content_management/v1/types.ts | 17 +- .../common/embeddable/extract.test.ts | 64 +++++++ .../common/embeddable/extract.ts | 35 ++++ .../common/embeddable/index.ts | 10 ++ .../common/embeddable/inject.test.ts | 69 ++++++++ .../common/embeddable/inject.ts | 40 +++++ .../common/embeddable/types.ts | 14 ++ .../common/persistable_state/index.ts | 9 + .../persistable_state/references.test.ts | 163 ++++++++++++++++++ .../common/persistable_state/references.ts | 81 +++++++++ .../navigation_embeddable/jest.config.js | 18 ++ .../dashboard_link_component.tsx | 2 +- .../navigation_embeddable_factory.ts | 15 +- .../public/services/attribute_service.ts | 17 +- .../navigation_embeddable/tsconfig.json | 1 + 20 files changed, 560 insertions(+), 24 deletions(-) create mode 100644 src/plugins/navigation_embeddable/common/embeddable/extract.test.ts create mode 100644 src/plugins/navigation_embeddable/common/embeddable/extract.ts create mode 100644 src/plugins/navigation_embeddable/common/embeddable/index.ts create mode 100644 src/plugins/navigation_embeddable/common/embeddable/inject.test.ts create mode 100644 src/plugins/navigation_embeddable/common/embeddable/inject.ts create mode 100644 src/plugins/navigation_embeddable/common/embeddable/types.ts create mode 100644 src/plugins/navigation_embeddable/common/persistable_state/index.ts create mode 100644 src/plugins/navigation_embeddable/common/persistable_state/references.test.ts create mode 100644 src/plugins/navigation_embeddable/common/persistable_state/references.ts create mode 100644 src/plugins/navigation_embeddable/jest.config.js diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts index 0989c46c6d975..efeaa76297f9e 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts @@ -80,6 +80,7 @@ export async function findDashboardById( id, status: 'success', attributes: cachedDashboard.item.attributes, + references: cachedDashboard.item.references, }; } /** Otherwise, fetch the dashboard from the content management client, add it to the cache, and return the result */ diff --git a/src/plugins/navigation_embeddable/common/content_management/index.ts b/src/plugins/navigation_embeddable/common/content_management/index.ts index 7b26870c7ce53..1dbb901c8cf8f 100644 --- a/src/plugins/navigation_embeddable/common/content_management/index.ts +++ b/src/plugins/navigation_embeddable/common/content_management/index.ts @@ -24,6 +24,7 @@ export { DASHBOARD_LINK_TYPE, NAV_VERTICAL_LAYOUT, NAV_HORIZONTAL_LAYOUT, + EXTERNAL_LINK_SUPPORTED_PROTOCOLS, } from './latest'; export * as NavigationEmbeddableV1 from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts index 3c9c7a1bb759c..9e4452cb09c0e 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts @@ -16,21 +16,35 @@ import { objectTypeToGetResultSchema, } from '@kbn/content-management-utils'; import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '.'; -import { NAV_HORIZONTAL_LAYOUT, NAV_VERTICAL_LAYOUT } from './constants'; +import { + EXTERNAL_LINK_SUPPORTED_PROTOCOLS, + NAV_HORIZONTAL_LAYOUT, + NAV_VERTICAL_LAYOUT, +} from './constants'; -const navigationEmbeddableLinkSchema = schema.object({ +const baseNavigationEmbeddableLinkSchema = { id: schema.string(), - type: schema.oneOf([schema.literal(DASHBOARD_LINK_TYPE), schema.literal(EXTERNAL_LINK_TYPE)]), - destination: schema.string(), label: schema.maybe(schema.string()), order: schema.number(), +}; + +const dashboardLinkSchema = schema.object({ + ...baseNavigationEmbeddableLinkSchema, + destinationRefName: schema.string(), + type: schema.literal(DASHBOARD_LINK_TYPE), +}); + +const externalLinkSchema = schema.object({ + ...baseNavigationEmbeddableLinkSchema, + type: schema.literal(EXTERNAL_LINK_TYPE), + destination: schema.uri({ scheme: EXTERNAL_LINK_SUPPORTED_PROTOCOLS }), }); const navigationEmbeddableAttributesSchema = schema.object( { title: schema.string(), description: schema.maybe(schema.string()), - links: schema.maybe(schema.arrayOf(navigationEmbeddableLinkSchema)), + links: schema.arrayOf(schema.oneOf([dashboardLinkSchema, externalLinkSchema])), layout: schema.maybe( schema.oneOf([schema.literal(NAV_HORIZONTAL_LAYOUT), schema.literal(NAV_VERTICAL_LAYOUT)]) ), diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts b/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts index 70f1af5c0f69d..a03894ba6b715 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts @@ -17,3 +17,5 @@ export const EXTERNAL_LINK_TYPE = 'externalLink'; */ export const NAV_HORIZONTAL_LAYOUT = 'horizontal'; export const NAV_VERTICAL_LAYOUT = 'vertical'; + +export const EXTERNAL_LINK_SUPPORTED_PROTOCOLS = ['http', 'https', 'mailto']; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts index efda7e1cf696c..b0af2ee1a936a 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts @@ -20,4 +20,5 @@ export { DASHBOARD_LINK_TYPE, NAV_VERTICAL_LAYOUT, NAV_HORIZONTAL_LAYOUT, + EXTERNAL_LINK_SUPPORTED_PROTOCOLS, } from './constants'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts index bb5c6c10c584b..ff93f6462915f 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts @@ -35,14 +35,25 @@ export type NavigationEmbeddableCrudTypes = ContentManagementCrudTypes< */ export type NavigationLinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; -export interface NavigationEmbeddableLink { +interface BaseNavigationEmbeddableLink { id: string; - type: NavigationLinkType; - destination: string; label?: string; order: number; + destination?: string; +} + +interface DashboardLink extends BaseNavigationEmbeddableLink { + type: typeof DASHBOARD_LINK_TYPE; + destinationRefName?: string; } +interface ExternalLink extends BaseNavigationEmbeddableLink { + type: typeof EXTERNAL_LINK_TYPE; + destination: string; +} + +export type NavigationEmbeddableLink = DashboardLink | ExternalLink; + export type NavigationLayoutType = typeof NAV_HORIZONTAL_LAYOUT | typeof NAV_VERTICAL_LAYOUT; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions diff --git a/src/plugins/navigation_embeddable/common/embeddable/extract.test.ts b/src/plugins/navigation_embeddable/common/embeddable/extract.test.ts new file mode 100644 index 0000000000000..1fe746b722f8a --- /dev/null +++ b/src/plugins/navigation_embeddable/common/embeddable/extract.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { extract } from './extract'; + +test('Should return original state and empty references with by-reference embeddable state', () => { + const navigationEmbeddableByReferenceInput = { + id: '2192e502-0ec7-4316-82fb-c9bbf78525c4', + type: 'navigation_embeddable', + }; + + expect(extract!(navigationEmbeddableByReferenceInput)).toEqual({ + state: navigationEmbeddableByReferenceInput, + references: [], + }); +}); + +test('Should update state with refNames with by-value embeddable state', () => { + const navigationEmbeddableByValueInput = { + id: '8d62c3f0-c61f-4c09-ac24-9b8ee4320e20', + attributes: { + links: [ + { + type: 'dashboardLink', + id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438', + destination: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824', + order: 0, + }, + ], + layout: 'horizontal', + }, + type: 'navigation_embeddable', + }; + + expect(extract!(navigationEmbeddableByValueInput)).toEqual({ + references: [ + { + name: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard', + type: 'dashboard', + id: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824', + }, + ], + state: { + id: '8d62c3f0-c61f-4c09-ac24-9b8ee4320e20', + attributes: { + links: [ + { + type: 'dashboardLink', + id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438', + destinationRefName: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard', + order: 0, + }, + ], + layout: 'horizontal', + }, + type: 'navigation_embeddable', + }, + }); +}); diff --git a/src/plugins/navigation_embeddable/common/embeddable/extract.ts b/src/plugins/navigation_embeddable/common/embeddable/extract.ts new file mode 100644 index 0000000000000..9d0e9c0c61b13 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/embeddable/extract.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; +import type { NavigationEmbeddableAttributes } from '../content_management'; +import { extractReferences } from '../persistable_state'; +import { NavigationEmbeddablePersistableState } from './types'; + +export const extract: EmbeddableRegistryDefinition['extract'] = (state) => { + const typedState = state as NavigationEmbeddablePersistableState; + + // by-reference embeddable + if (!('attributes' in typedState) || typedState.attributes === undefined) { + // No references to extract for by-reference embeddable since all references are stored with by-reference saved object + return { state, references: [] }; + } + + // by-value embeddable + const { attributes, references } = extractReferences({ + attributes: typedState.attributes as unknown as NavigationEmbeddableAttributes, + }); + + return { + state: { + ...state, + attributes, + }, + references, + }; +}; diff --git a/src/plugins/navigation_embeddable/common/embeddable/index.ts b/src/plugins/navigation_embeddable/common/embeddable/index.ts new file mode 100644 index 0000000000000..c526b0bf9bff8 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/embeddable/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { inject } from './inject'; +export { extract } from './extract'; diff --git a/src/plugins/navigation_embeddable/common/embeddable/inject.test.ts b/src/plugins/navigation_embeddable/common/embeddable/inject.test.ts new file mode 100644 index 0000000000000..bf0d9439cb811 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/embeddable/inject.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { inject } from './inject'; + +test('Should return original state with by-reference embeddable state', () => { + const navigationEmbeddableByReferenceInput = { + id: 'ea40fd4e-216c-49a7-917f-f733c8a2c817', + type: 'navigation_embeddable', + }; + + const references = [ + { + name: 'panel_ea40fd4e-216c-49a7-917f-f733c8a2c817', + type: 'navigation_embeddable', + id: '7f92d7d0-8e5f-11ec-9477-312c8a6de896', + }, + ]; + + expect(inject!(navigationEmbeddableByReferenceInput, references)).toEqual( + navigationEmbeddableByReferenceInput + ); +}); + +test('Should inject refNames with by-value embeddable state', () => { + const navigationEmbeddableByValueInput = { + id: 'c3937cf9-29be-43df-a4af-a4df742d7d35', + attributes: { + links: [ + { + type: 'dashboardLink', + id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438', + destinationRefName: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard', + order: 0, + }, + ], + layout: 'horizontal', + }, + type: 'navigation_embeddable', + }; + const references = [ + { + name: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard', + type: 'dashboard', + id: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824', + }, + ]; + + expect(inject!(navigationEmbeddableByValueInput, references)).toEqual({ + id: 'c3937cf9-29be-43df-a4af-a4df742d7d35', + attributes: { + links: [ + { + type: 'dashboardLink', + id: 'fc7b8c70-2eb9-40b2-936d-457d1721a438', + destination: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824', + order: 0, + }, + ], + layout: 'horizontal', + }, + type: 'navigation_embeddable', + }); +}); diff --git a/src/plugins/navigation_embeddable/common/embeddable/inject.ts b/src/plugins/navigation_embeddable/common/embeddable/inject.ts new file mode 100644 index 0000000000000..ccf06491407ea --- /dev/null +++ b/src/plugins/navigation_embeddable/common/embeddable/inject.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; +import { NavigationEmbeddableAttributes } from '../content_management'; +import { injectReferences } from '../persistable_state'; +import { NavigationEmbeddablePersistableState } from './types'; + +export const inject: EmbeddableRegistryDefinition['inject'] = (state, references) => { + const typedState = state as NavigationEmbeddablePersistableState; + + // by-reference embeddable + if (!('attributes' in typedState) || typedState.attributes === undefined) { + return typedState; + } + + // by-value embeddable + try { + const { attributes: attributesWithInjectedIds } = injectReferences({ + attributes: typedState.attributes as unknown as NavigationEmbeddableAttributes, + references, + }); + + return { + ...typedState, + attributes: attributesWithInjectedIds, + }; + } catch (error) { + // inject exception prevents entire dashboard from display + // Instead of throwing, swallow error and let dashboard display + // Errors will surface in navigation embeddable panel. + // Users can then manually edit links to resolve any problems. + return typedState; + } +}; diff --git a/src/plugins/navigation_embeddable/common/embeddable/types.ts b/src/plugins/navigation_embeddable/common/embeddable/types.ts new file mode 100644 index 0000000000000..80386fbfc1b2b --- /dev/null +++ b/src/plugins/navigation_embeddable/common/embeddable/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; +import { SerializableRecord } from '@kbn/utility-types'; + +export type NavigationEmbeddablePersistableState = EmbeddableStateWithType & { + attributes: SerializableRecord; +}; diff --git a/src/plugins/navigation_embeddable/common/persistable_state/index.ts b/src/plugins/navigation_embeddable/common/persistable_state/index.ts new file mode 100644 index 0000000000000..c3e09839f0f2f --- /dev/null +++ b/src/plugins/navigation_embeddable/common/persistable_state/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { extractReferences, injectReferences } from './references'; diff --git a/src/plugins/navigation_embeddable/common/persistable_state/references.test.ts b/src/plugins/navigation_embeddable/common/persistable_state/references.test.ts new file mode 100644 index 0000000000000..cf74ba929b1aa --- /dev/null +++ b/src/plugins/navigation_embeddable/common/persistable_state/references.test.ts @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '../content_management'; +import { extractReferences, injectReferences } from './references'; + +describe('extractReferences', () => { + test('should handle missing links attribute', () => { + const attributes = { + title: 'my links', + }; + expect(extractReferences({ attributes })).toEqual({ + attributes: { + title: 'my links', + }, + references: [], + }); + }); + + test('should extract dashboard references from dashboard links', () => { + const attributes = { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destination: '19e149f0-e95e-404b-b6f8-fc751317c6be', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: EXTERNAL_LINK_TYPE as typeof EXTERNAL_LINK_TYPE, + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destination: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + order: 2, + }, + ], + }; + expect(extractReferences({ attributes })).toEqual({ + attributes: { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: 'dashboardLink', + destinationRefName: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: 'externalLink', + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: 'dashboardLink', + destinationRefName: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + order: 2, + }, + ], + }, + references: [ + { + id: '19e149f0-e95e-404b-b6f8-fc751317c6be', + name: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + type: 'dashboard', + }, + { + id: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + name: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + type: 'dashboard', + }, + ], + }); + }); +}); + +describe('injectReferences', () => { + test('should handle missing links attribute', () => { + const attributes = { + title: 'my links', + }; + expect(injectReferences({ attributes, references: [] })).toEqual({ + attributes: { + title: 'my links', + }, + }); + }); + + test('should inject dashboard references into dashboard links', () => { + const attributes = { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destinationRefName: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: EXTERNAL_LINK_TYPE as typeof EXTERNAL_LINK_TYPE, + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destinationRefName: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + order: 2, + }, + ], + }; + const references = [ + { + id: '19e149f0-e95e-404b-b6f8-fc751317c6be', + name: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + type: 'dashboard', + }, + { + id: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + name: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + type: 'dashboard', + }, + ]; + expect(injectReferences({ attributes, references })).toEqual({ + attributes: { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: 'dashboardLink', + destination: '19e149f0-e95e-404b-b6f8-fc751317c6be', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: 'externalLink', + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: 'dashboardLink', + destination: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + order: 2, + }, + ], + }, + }); + }); +}); diff --git a/src/plugins/navigation_embeddable/common/persistable_state/references.ts b/src/plugins/navigation_embeddable/common/persistable_state/references.ts new file mode 100644 index 0000000000000..8cb0047534279 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/persistable_state/references.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Reference } from '@kbn/content-management-utils'; +import { DASHBOARD_LINK_TYPE, NavigationEmbeddableAttributes } from '../content_management'; + +export function extractReferences({ + attributes, + references = [], +}: { + attributes: NavigationEmbeddableAttributes; + references?: Reference[]; +}) { + if (!attributes.links) { + return { attributes, references }; + } + + const { links } = attributes; + const extractedReferences: Reference[] = []; + links.forEach((link) => { + if (link.type === DASHBOARD_LINK_TYPE && link.destination) { + const refName = `link_${link.id}_dashboard`; + link.destinationRefName = refName; + extractedReferences.push({ + name: refName, + type: 'dashboard', + id: link.destination, + }); + delete link.destination; + } + }); + + return { + attributes: { + ...attributes, + links, + }, + references: references.concat(extractedReferences), + }; +} + +function findReference(targetName: string, references: Reference[]) { + const reference = references.find(({ name }) => name === targetName); + if (!reference) { + throw new Error(`Could not find reference "${targetName}"`); + } + return reference; +} + +export function injectReferences({ + attributes, + references, +}: { + attributes: NavigationEmbeddableAttributes; + references: Reference[]; +}) { + if (!attributes.links) { + return { attributes }; + } + + const { links } = attributes; + links.forEach((link) => { + if (link.type === DASHBOARD_LINK_TYPE && link.destinationRefName) { + const reference = findReference(link.destinationRefName, references); + link.destination = reference.id; + delete link.destinationRefName; + } + }); + + return { + attributes: { + ...attributes, + links, + }, + }; +} diff --git a/src/plugins/navigation_embeddable/jest.config.js b/src/plugins/navigation_embeddable/jest.config.js new file mode 100644 index 0000000000000..d864b4a234669 --- /dev/null +++ b/src/plugins/navigation_embeddable/jest.config.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/navigation_embeddable'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/navigation_embeddable', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/navigation_embeddable/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx index 8259d98cce4b6..00f12d65b36c1 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx @@ -42,7 +42,7 @@ export const DashboardLinkComponent = ({ const { loading: loadingDestinationDashboard, value: destinationDashboard } = useAsync(async () => { - if (link.id !== parentDashboardId) { + if (link.id !== parentDashboardId && link.destination) { /** * only fetch the dashboard if it's not the current dashboard - if it is the current dashboard, * use `dashboardContainer` and its corresponding state (title, description, etc.) instead. diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts index 3226833f1f9c8..32aab131ddb1b 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts @@ -12,7 +12,6 @@ import { ErrorEmbeddable, } from '@kbn/embeddable-plugin/public'; import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; -import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { NavigationEmbeddableByReferenceInput, NavigationEmbeddableInput } from './types'; @@ -20,6 +19,7 @@ import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; import type { NavigationEmbeddable } from './navigation_embeddable'; import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; +import { extract, inject } from '../../common/embeddable'; export type NavigationEmbeddableFactory = EmbeddableFactory; @@ -41,15 +41,6 @@ export class NavigationEmbeddableFactoryDefinition getIconForSavedObject: () => APP_ICON, }; - // TODO create functions - // public inject: EmbeddablePersistableStateService['inject']; - // public extract: EmbeddablePersistableStateService['extract']; - - constructor(persistableStateService: EmbeddablePersistableStateService) { - // this.inject = createInject(this.persistableStateService); - // this.extract = createExtract(this.persistableStateService); - } - public async isEditable() { await untilPluginStartServicesReady(); return Boolean(coreServices.application.capabilities.dashboard?.showWriteControls); @@ -116,4 +107,8 @@ export class NavigationEmbeddableFactoryDefinition public getIconType() { return 'link'; } + + inject = inject; + + extract = extract; } diff --git a/src/plugins/navigation_embeddable/public/services/attribute_service.ts b/src/plugins/navigation_embeddable/public/services/attribute_service.ts index 7a7dbfe2bd139..74530871fc469 100644 --- a/src/plugins/navigation_embeddable/public/services/attribute_service.ts +++ b/src/plugins/navigation_embeddable/public/services/attribute_service.ts @@ -11,6 +11,7 @@ import { AttributeService } from '@kbn/embeddable-plugin/public'; import type { OnSaveProps } from '@kbn/saved-objects-plugin/public'; import { SharingSavedObjectProps } from '../../common/types'; import { NavigationEmbeddableAttributes } from '../../common/content_management'; +import { extractReferences, injectReferences } from '../../common/persistable_state'; import { NavigationEmbeddableByReferenceInput, NavigationEmbeddableByValueInput, @@ -45,12 +46,19 @@ export function getNavigationEmbeddableAttributeService(): NavigationEmbeddableA NavigationEmbeddableUnwrapMetaInfo >(CONTENT_ID, { saveMethod: async (attributes: NavigationEmbeddableDocument, savedObjectId?: string) => { - // TODO extract references + const { attributes: updatedAttributes, references } = extractReferences({ + attributes, + references: attributes.references, + }); const { item: { id }, } = await (savedObjectId - ? navigationEmbeddableClient.update({ id: savedObjectId, data: attributes }) - : navigationEmbeddableClient.create({ data: attributes, options: { references: [] } })); + ? navigationEmbeddableClient.update({ + id: savedObjectId, + data: updatedAttributes, + options: { references }, + }) + : navigationEmbeddableClient.create({ data: updatedAttributes, options: { references } })); return { id }; }, unwrapMethod: async ( @@ -65,8 +73,7 @@ export function getNavigationEmbeddableAttributeService(): NavigationEmbeddableA } = await navigationEmbeddableClient.get(savedObjectId); if (savedObject.error) throw savedObject.error; - // TODO inject references - const attributes = savedObject.attributes; + const { attributes } = injectReferences(savedObject); return { attributes, metaInfo: { diff --git a/src/plugins/navigation_embeddable/tsconfig.json b/src/plugins/navigation_embeddable/tsconfig.json index ae66fa7cc3144..afcc5945c2082 100644 --- a/src/plugins/navigation_embeddable/tsconfig.json +++ b/src/plugins/navigation_embeddable/tsconfig.json @@ -20,6 +20,7 @@ "@kbn/saved-objects-plugin", "@kbn/core-saved-objects-server", "@kbn/saved-objects-plugin", + "@kbn/utility-types", ], "exclude": ["target/**/*"] } From 84b7b175c1525feb31d79d002d99677c14ebb547 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 5 Sep 2023 12:22:17 -0600 Subject: [PATCH 13/53] [Dashboard Navigation] Drilldown on link click (#164196) Closes https://github.com/elastic/kibana/issues/154381 ## Summary This PR adds functionality to the link embeddable so that 1) dashboard to dashboard links will now navigate between dashboards, taking the context (filters, time range, queries, etc.) as the user navigates (assuming the appropriate settings are applied) and 2) URL links will now navigate to internal and/or external URLs as expected ### Dashboard Links https://github.com/elastic/kibana/assets/8698078/1034b454-3add-48c2-8505-44d89e2d87d0 > **Note** > In the above video, all links were configured to include queries, filter pills, and the selected time range in the passed context. It is possible to disable these settings, if necessary. #### Link settings We want dashboard drilldowns and dashboard links to work more-or-less the same way, which means that they should have identical settings - therefore, in order to share the same settings between dashboard drilldowns + dashboard links, I created the `DashboardDrilldownOptionsComponent`: ![image](https://github.com/elastic/kibana/assets/8698078/5c809c7a-c9ef-43d7-b2ea-1d706858228a) I put this new component into `presentation_util` so that it could easily be shared and used in the respective editing experiences. ### Testing - When testing, special attention should be paid to the different link settings - ensure that all settings are respected. - Ensure that `ctrl + click`, `shift + click`, and `meta + click` work as expected, regardless of whether the `Open in new tab` setting is true or false - specifically, we should use the **URL** to pass the filters/queries/time range whenever a dashboard is being opened in a new tab. ### URL Links https://github.com/elastic/kibana/assets/8698078/5abf9772-17b3-4ab4-a704-4a3ff432fa23 #### Link settings Similar to the dashboard links, we want the ![image](https://github.com/elastic/kibana/assets/8698078/e9de2c81-fb2e-4d53-a610-7327466e4246) ### Testing When testing, it is important to ensure that the new URL validation works as expected. For this, there are technically two types of invalid links: 1) links that do not fit the expected format; for example, we do not accept any links that start with something other than `http`, `https`, or `mailto`. 2) links that are disallowed due to the `externalUrl.policy` Testing number one should be simple; however, in order to test the second type of invalid links, you need to modify your `kibana.dev.yml` to include something like the following: ``` externalUrl.policy: - allow: false host: danger.example.com - allow: true host: example.com protocol: https ``` Refer to http://www.elastic.co/guide/en/kibana/current/url-drilldown-settings-kb.html for more information. Also, much like with the dashboard links, it is important to ensure that `ctrl + click`, `shift + click`, and `meta + click` work as expected, regardless of whether the `Open in new tab` setting is true or false. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] ~[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~ Will be addressed in https://github.com/elastic/kibana/issues/161287 - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) > **Note** > How Chrome and Firefox handle modified `onClick` events varies significantly and, through this testing, I found a bug where `shift + click`ing was previously not opening the links in a new tab in Firefox specifically - this is why I had to use the default to `href` behaviour whenever possible. Something to keep in mind when testing. ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Nick Peihl Co-authored-by: Nick Peihl --- .../locator/get_dashboard_locator_params.ts | 47 +++++++ .../embeddable/api/panel_management.ts | 1 - src/plugins/dashboard/public/index.ts | 1 + .../common/content_management/index.ts | 2 +- .../content_management/v1/cm_services.ts | 27 +++- .../common/content_management/v1/constants.ts | 2 - .../common/content_management/v1/index.ts | 2 +- .../common/content_management/v1/types.ts | 5 + .../navigation_embeddable/kibana.jsonc | 9 +- .../dashboard_link_component.tsx | 125 ++++++++++++------ .../dashboard_link_destination_picker.tsx | 12 +- ...link_tools.tsx => dashboard_link_tools.ts} | 69 +++++++++- ...navigation_embeddable_link_destination.tsx | 80 +++++++++++ .../navigation_embeddable_link_editor.tsx | 84 ++++-------- .../navigation_embeddable_link_options.tsx | 71 ++++++++++ .../navigation_embeddable_panel_editor.tsx | 4 +- ...avigation_embeddable_panel_editor_link.tsx | 40 ++++-- .../external_link/external_link_component.tsx | 70 +++++++++- .../external_link_destination_picker.tsx | 52 ++++++-- .../external_link/external_link_strings.ts | 12 ++ .../external_link/external_link_tools.ts | 35 +++++ .../navigation_embeddable_component.tsx | 6 +- .../navigation_embeddable_strings.ts | 4 + .../navigation_embeddable_factory.ts | 3 +- .../navigation_embeddable/tsconfig.json | 6 +- .../dashboard_drilldown_options.tsx | 52 ++++++++ .../dashboard_drilldown_options/types.ts | 20 +++ .../public/components/index.tsx | 18 +++ .../i18n/dashboard_drilldown_config.tsx | 29 ++++ src/plugins/presentation_util/public/index.ts | 3 + .../url_drilldown/components/index.ts | 5 +- .../url_drilldown_collect_config/i18n.ts | 2 +- .../url_drilldown_collect_config/index.ts | 2 +- .../url_drilldown_collect_config/lazy.tsx | 15 +++ .../test_samples/demo.tsx | 1 + .../url_drilldown_collect_config.tsx | 46 ++----- .../url_drilldown_options.tsx | 57 ++++++++ .../drilldowns/url_drilldown/constants.ts | 14 ++ .../public/drilldowns/url_drilldown/index.ts | 10 +- .../public/drilldowns/url_drilldown/types.ts | 8 +- .../ui_actions_enhanced/public/index.ts | 3 + .../dashboard_drilldown_persistable_state.ts | 6 +- .../drilldowns/dashboard_drilldown/index.ts | 2 +- .../drilldowns/dashboard_drilldown/types.ts | 10 +- .../plugins/dashboard_enhanced/kibana.jsonc | 8 +- .../abstract_dashboard_drilldown.tsx | 12 +- .../components/collect_config_container.tsx | 26 +--- .../dashboard_drilldown_config.tsx | 68 +++------- .../dashboard_drilldown_config/i18n.ts | 21 --- .../abstract_dashboard_drilldown/types.ts | 4 +- .../embeddable_to_dashboard_drilldown.tsx | 44 ++---- .../plugins/dashboard_enhanced/tsconfig.json | 15 +-- .../public/lib/url_drilldown.test.ts | 8 ++ .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 56 files changed, 920 insertions(+), 367 deletions(-) create mode 100644 src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts rename src/plugins/navigation_embeddable/public/components/dashboard_link/{dashboard_link_tools.tsx => dashboard_link_tools.ts} (55%) create mode 100644 src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_link_destination.tsx create mode 100644 src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_link_options.tsx create mode 100644 src/plugins/navigation_embeddable/public/components/external_link/external_link_tools.ts create mode 100644 src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx create mode 100644 src/plugins/presentation_util/public/components/dashboard_drilldown_options/types.ts create mode 100644 src/plugins/presentation_util/public/i18n/dashboard_drilldown_config.tsx create mode 100644 src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx create mode 100644 src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/constants.ts diff --git a/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts b/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts new file mode 100644 index 0000000000000..f2805d10c29da --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isQuery, isTimeRange } from '@kbn/data-plugin/common'; +import { Filter, isFilterPinned, Query, TimeRange } from '@kbn/es-query'; +import { EmbeddableInput, IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; + +import { DashboardAppLocatorParams } from './locator'; + +interface EmbeddableQueryInput extends EmbeddableInput { + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; +} + +export const getEmbeddableParams = ( + source: IEmbeddable, + options: DashboardDrilldownOptions +): Partial => { + const params: DashboardAppLocatorParams = {}; + + const input = source.getInput(); + if (isQuery(input.query) && options.useCurrentFilters) { + params.query = input.query; + } + + // if useCurrentDashboardDataRange is enabled, then preserve current time range + // if undefined is passed, then destination dashboard will figure out time range itself + // for brush event this time range would be overwritten + if (isTimeRange(input.timeRange) && options.useCurrentDateRange) { + params.timeRange = input.timeRange; + } + + // if useCurrentDashboardFilters enabled, then preserve all the filters (pinned, unpinned, and from controls) + // otherwise preserve only pinned + params.filters = options.useCurrentFilters + ? input.filters + : input.filters?.filter((f) => isFilterPinned(f)); + + return params; +}; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts index a72bb88a56123..2a5662387c477 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts @@ -12,7 +12,6 @@ import { IEmbeddable, PanelState, } from '@kbn/embeddable-plugin/public'; -import { v4 as uuidv4 } from 'uuid'; import { DashboardPanelState } from '../../../../common'; import { DashboardContainer } from '../dashboard_container'; diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 0bfe5dfae8b4c..290f4b7c10f28 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -30,6 +30,7 @@ export { type DashboardAppLocatorParams, cleanEmptyKeys, } from './dashboard_app/locator/locator'; +export { getEmbeddableParams } from './dashboard_app/locator/get_dashboard_locator_params'; export function plugin(initializerContext: PluginInitializerContext) { return new DashboardPlugin(initializerContext); diff --git a/src/plugins/navigation_embeddable/common/content_management/index.ts b/src/plugins/navigation_embeddable/common/content_management/index.ts index 1dbb901c8cf8f..118b02d9ea1e2 100644 --- a/src/plugins/navigation_embeddable/common/content_management/index.ts +++ b/src/plugins/navigation_embeddable/common/content_management/index.ts @@ -13,6 +13,7 @@ export type { NavigationEmbeddableContentType } from '../types'; export type { NavigationLinkType, NavigationLayoutType, + NavigationLinkOptions, NavigationEmbeddableLink, NavigationEmbeddableItem, NavigationEmbeddableCrudTypes, @@ -24,7 +25,6 @@ export { DASHBOARD_LINK_TYPE, NAV_VERTICAL_LAYOUT, NAV_HORIZONTAL_LAYOUT, - EXTERNAL_LINK_SUPPORTED_PROTOCOLS, } from './latest'; export * as NavigationEmbeddableV1 from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts index 9e4452cb09c0e..4145f0678f16e 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts @@ -16,11 +16,7 @@ import { objectTypeToGetResultSchema, } from '@kbn/content-management-utils'; import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '.'; -import { - EXTERNAL_LINK_SUPPORTED_PROTOCOLS, - NAV_HORIZONTAL_LAYOUT, - NAV_VERTICAL_LAYOUT, -} from './constants'; +import { NAV_HORIZONTAL_LAYOUT, NAV_VERTICAL_LAYOUT } from './constants'; const baseNavigationEmbeddableLinkSchema = { id: schema.string(), @@ -32,12 +28,31 @@ const dashboardLinkSchema = schema.object({ ...baseNavigationEmbeddableLinkSchema, destinationRefName: schema.string(), type: schema.literal(DASHBOARD_LINK_TYPE), + options: schema.maybe( + schema.object( + { + openInNewTab: schema.boolean(), + useCurrentFilters: schema.boolean(), + useCurrentDateRange: schema.boolean(), + }, + { unknowns: 'forbid' } + ) + ), }); const externalLinkSchema = schema.object({ ...baseNavigationEmbeddableLinkSchema, type: schema.literal(EXTERNAL_LINK_TYPE), - destination: schema.uri({ scheme: EXTERNAL_LINK_SUPPORTED_PROTOCOLS }), + destination: schema.string(), + options: schema.maybe( + schema.object( + { + openInNewTab: schema.boolean(), + encodeUrl: schema.boolean(), + }, + { unknowns: 'forbid' } + ) + ), }); const navigationEmbeddableAttributesSchema = schema.object( diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts b/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts index a03894ba6b715..70f1af5c0f69d 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts @@ -17,5 +17,3 @@ export const EXTERNAL_LINK_TYPE = 'externalLink'; */ export const NAV_HORIZONTAL_LAYOUT = 'horizontal'; export const NAV_VERTICAL_LAYOUT = 'vertical'; - -export const EXTERNAL_LINK_SUPPORTED_PROTOCOLS = ['http', 'https', 'mailto']; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts index b0af2ee1a936a..317db65b4ebbb 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts @@ -11,6 +11,7 @@ export type { NavigationEmbeddableCrudTypes, NavigationEmbeddableAttributes, NavigationEmbeddableLink, + NavigationLinkOptions, NavigationLayoutType, NavigationLinkType, } from './types'; @@ -20,5 +21,4 @@ export { DASHBOARD_LINK_TYPE, NAV_VERTICAL_LAYOUT, NAV_HORIZONTAL_LAYOUT, - EXTERNAL_LINK_SUPPORTED_PROTOCOLS, } from './constants'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts index ff93f6462915f..0e3d48d45a6d2 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts +++ b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts @@ -11,6 +11,9 @@ import type { SavedObjectCreateOptions, SavedObjectUpdateOptions, } from '@kbn/content-management-utils'; +import { type UrlDrilldownOptions } from '@kbn/ui-actions-enhanced-plugin/public'; +import { type DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; + import { NavigationEmbeddableContentType } from '../../types'; import { DASHBOARD_LINK_TYPE, @@ -35,10 +38,12 @@ export type NavigationEmbeddableCrudTypes = ContentManagementCrudTypes< */ export type NavigationLinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; +export type NavigationLinkOptions = DashboardDrilldownOptions | UrlDrilldownOptions; interface BaseNavigationEmbeddableLink { id: string; label?: string; order: number; + options?: NavigationLinkOptions; destination?: string; } diff --git a/src/plugins/navigation_embeddable/kibana.jsonc b/src/plugins/navigation_embeddable/kibana.jsonc index b74e4bbd6f330..8d90360be090e 100644 --- a/src/plugins/navigation_embeddable/kibana.jsonc +++ b/src/plugins/navigation_embeddable/kibana.jsonc @@ -12,12 +12,11 @@ "dashboard", "embeddable", "kibanaReact", - "presentationUtil" + "presentationUtil", + "uiActionsEnhanced", + "kibanaUtils" ], "optionalPlugins": ["triggersActionsUi"], - "requiredBundles": [ - "savedObjects" - ] + "requiredBundles": ["savedObjects"] } } - diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx index 00f12d65b36c1..71627054ea430 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx @@ -9,18 +9,24 @@ import classNames from 'classnames'; import useAsync from 'react-use/lib/useAsync'; import React, { useMemo, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; -import { EuiButtonEmpty, EuiListGroupItem, EuiToolTip } from '@elastic/eui'; +import { + DashboardDrilldownOptions, + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, +} from '@kbn/presentation-util-plugin/public'; +import { EuiButtonEmpty, EuiListGroupItem } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { - NavigationEmbeddableLink, - NavigationLayoutType, NAV_VERTICAL_LAYOUT, + NavigationLayoutType, + NavigationEmbeddableLink, } from '../../../common/content_management'; -import { fetchDashboard } from './dashboard_link_tools'; +import { coreServices } from '../../services/kibana_services'; import { DashboardLinkStrings } from './dashboard_link_strings'; import { useNavigationEmbeddable } from '../../embeddable/navigation_embeddable'; +import { fetchDashboard, getDashboardHref, getDashboardLocator } from './dashboard_link_tools'; export const DashboardLinkComponent = ({ link, @@ -33,13 +39,10 @@ export const DashboardLinkComponent = ({ const [error, setError] = useState(); const dashboardContainer = navEmbeddable.parent as DashboardContainer; - const parentDashboardTitle = dashboardContainer.select((state) => state.explicitInput.title); - const parentDashboardDescription = dashboardContainer.select( - (state) => state.explicitInput.description - ); - + const parentDashboardInput = useObservable(dashboardContainer.getInput$()); const parentDashboardId = dashboardContainer.select((state) => state.componentState.lastSavedId); + /** Fetch the dashboard that the link is pointing to */ const { loading: loadingDestinationDashboard, value: destinationDashboard } = useAsync(async () => { if (link.id !== parentDashboardId && link.destination) { @@ -57,18 +60,20 @@ export const DashboardLinkComponent = ({ } }, [link, parentDashboardId]); + /** + * Returns the title and description of the dashboard that the link points to; note that, if the link points to + * the current dashboard, then we need to get the most up-to-date information via the `parentDashboardInput` - this + * will respond to changes so that the link label/tooltip remains in sync with the dashboard title/description. + */ const [dashboardTitle, dashboardDescription] = useMemo(() => { return link.destination === parentDashboardId - ? [parentDashboardTitle, parentDashboardDescription] + ? [parentDashboardInput?.title, parentDashboardInput?.description] : [destinationDashboard?.attributes.title, destinationDashboard?.attributes.description]; - }, [ - link.destination, - parentDashboardId, - parentDashboardTitle, - destinationDashboard, - parentDashboardDescription, - ]); + }, [link.destination, parentDashboardId, parentDashboardInput, destinationDashboard]); + /** + * Memoized link information + */ const linkLabel = useMemo(() => { return link.label || (dashboardTitle ?? DashboardLinkStrings.getDashboardErrorLabel()); }, [link, dashboardTitle]); @@ -81,10 +86,56 @@ export const DashboardLinkComponent = ({ }; } return { - tooltipTitle: Boolean(dashboardDescription) ? dashboardTitle : undefined, - tooltipMessage: dashboardDescription || dashboardTitle, + tooltipTitle: Boolean(dashboardDescription) ? linkLabel : undefined, + tooltipMessage: dashboardDescription || linkLabel, + }; + }, [error, linkLabel, dashboardDescription]); + + /** + * Dashboard-to-dashboard navigation + */ + const { loading: loadingOnClickProps, value: onClickProps } = useAsync(async () => { + /** If the link points to the current dashboard, then there should be no `onClick` or `href` prop */ + if (link.destination === parentDashboardId) return; + + const linkOptions = { + ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, + ...link.options, + } as DashboardDrilldownOptions; + + const locator = await getDashboardLocator({ + link: { ...link, options: linkOptions }, + navEmbeddable, + }); + if (!locator) return; + + const href = getDashboardHref(locator); + return { + href, + onClick: async (event: React.MouseEvent) => { + /** + * If the link is being opened via a modified click, then we should use the default `href` navigation behaviour + * by passing all the dashboard state via the URL - this will keep behaviour consistent across all browsers. + */ + const modifiedClick = event.ctrlKey || event.metaKey || event.shiftKey; + if (modifiedClick) { + return; + } + + /** Otherwise, prevent the default behaviour and handle click depending on `openInNewTab` option */ + event.preventDefault(); + if (linkOptions.openInNewTab) { + window.open(href, '_blank'); + } else { + const { app, path, state } = locator; + await coreServices.application.navigateToApp(app, { + path, + state, + }); + } + }, }; - }, [error, dashboardTitle, dashboardDescription]); + }, [link]); return loadingDestinationDashboard ? ( @@ -156,6 +160,7 @@ export const DashboardLinkComponent = ({ position: layout === NAV_VERTICAL_LAYOUT ? 'right' : 'bottom', repositionOnScroll: true, delay: 'long', + 'data-test-subj': `dashboardLink--${link.id}--tooltip`, }} iconType={error ? 'warning' : undefined} iconProps={{ className: 'dashboardLinkIcon' }} @@ -166,6 +171,7 @@ export const DashboardLinkComponent = ({ 'dashboardLinkError--noLabel': !link.label, })} label={linkLabel} + data-test-subj={error ? `dashboardLink--${link.id}--error` : `dashboardLink--${link.id}`} /> ); }; diff --git a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.test.tsx b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.test.tsx new file mode 100644 index 0000000000000..250695cfc0a1d --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.test.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import NavigationEmbeddablePanelEditor from './navigation_embeddable_panel_editor'; +import { NavEmbeddableStrings } from '../navigation_embeddable_strings'; +import { NavigationEmbeddableLink, NAV_VERTICAL_LAYOUT } from '../../../common/content_management'; +import { fetchDashboard } from '../dashboard_link/dashboard_link_tools'; + +jest.mock('../dashboard_link/dashboard_link_tools', () => { + return { + fetchDashboard: jest.fn().mockImplementation((id: string) => + Promise.resolve({ + id, + status: 'success', + attributes: { + title: `dashboard #${id}`, + description: '', + panelsJSON: [], + timeRestore: false, + version: '1', + }, + references: [], + }) + ), + }; +}); + +describe('NavigationEmbeddablePanelEditor', () => { + const defaultProps = { + onSaveToLibrary: jest.fn().mockImplementation(() => Promise.resolve()), + onAddToDashboard: jest.fn(), + onClose: jest.fn(), + isByReference: false, + }; + + const someLinks: NavigationEmbeddableLink[] = [ + { + id: 'foo', + type: 'dashboardLink' as const, + order: 1, + destination: '123', + }, + { + id: 'bar', + type: 'dashboardLink' as const, + order: 4, + destination: '456', + }, + { + id: 'bizz', + type: 'externalLink' as const, + order: 3, + destination: 'http://example.com', + }, + { + id: 'buzz', + type: 'externalLink' as const, + order: 2, + destination: 'http://elastic.co', + }, + ]; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('shows empty state with no links', async () => { + render(); + expect(screen.getByTestId('navEmbeddable--panelEditor--title')).toHaveTextContent( + NavEmbeddableStrings.editor.panelEditor.getCreateFlyoutTitle() + ); + expect(screen.getByTestId('navEmbeddable--panelEditor--emptyPrompt')).toBeInTheDocument(); + expect(screen.getByTestId('navEmbeddable--panelEditor--saveBtn')).toBeDisabled(); + + await userEvent.click(screen.getByTestId('navEmbeddable--panelEditor--closeBtn')); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + test('shows links in order', async () => { + const expectedLinkIds = [...someLinks].sort((a, b) => a.order - b.order).map(({ id }) => id); + render(); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId('navEmbeddable--panelEditor--title')).toHaveTextContent( + NavEmbeddableStrings.editor.panelEditor.getEditFlyoutTitle() + ); + const draggableLinks = screen.getAllByTestId('navEmbeddable--panelEditor--draggableLink'); + expect(draggableLinks.length).toEqual(4); + + draggableLinks.forEach((link, idx) => { + expect(link).toHaveAttribute('data-rfd-draggable-id', expectedLinkIds[idx]); + }); + }); + + test('saving by reference panels calls onSaveToLibrary', async () => { + const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order); + render( + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); + const saveButton = screen.getByTestId('navEmbeddable--panelEditor--saveBtn'); + await userEvent.click(saveButton); + await waitFor(() => expect(defaultProps.onSaveToLibrary).toHaveBeenCalledTimes(1)); + expect(defaultProps.onSaveToLibrary).toHaveBeenCalledWith(orderedLinks, NAV_VERTICAL_LAYOUT); + }); + + test('saving by value panel calls onAddToDashboard', async () => { + const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order); + render( + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); + const saveButton = screen.getByTestId('navEmbeddable--panelEditor--saveBtn'); + await userEvent.click(saveButton); + expect(defaultProps.onAddToDashboard).toHaveBeenCalledTimes(1); + expect(defaultProps.onAddToDashboard).toHaveBeenCalledWith(orderedLinks, NAV_VERTICAL_LAYOUT); + }); +}); diff --git a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.tsx b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.tsx index 5cac5674daf52..3028c22fa48b3 100644 --- a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.tsx @@ -159,7 +159,7 @@ const NavigationEmbeddablePanelEditor = ({ <>
- +

{isEditingExisting ? NavEmbeddableStrings.editor.panelEditor.getEditFlyoutTitle() @@ -201,6 +201,7 @@ const NavigationEmbeddablePanelEditor = ({ draggableId={link.id} customDragHandle={true} hasInteractiveChildren={true} + data-test-subj={`navEmbeddable--panelEditor--draggableLink`} > {(provided) => ( - + {NavEmbeddableStrings.editor.getCancelButtonLabel()} @@ -243,12 +249,14 @@ const NavigationEmbeddablePanelEditor = ({ setSaveByReference(!saveByReference)} + data-test-subj="navEmbeddable--panelEditor--saveByReferenceSwitch" /> @@ -257,11 +265,13 @@ const NavigationEmbeddablePanelEditor = ({ { if (saveByReference) { setIsSaving(true); diff --git a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx index 1e4d1a30589f8..1fd18f4730837 100644 --- a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx +++ b/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx @@ -18,7 +18,7 @@ export const NavigationEmbeddablePanelEditorEmptyPrompt = ({ addLink: () => Promise; }) => { return ( - + diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.test.tsx b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.test.tsx new file mode 100644 index 0000000000000..1945c9e762a7d --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import userEvent from '@testing-library/user-event'; +import { createEvent, fireEvent, render, screen } from '@testing-library/react'; +import { + NavigationEmbeddable, + NavigationEmbeddableContext, +} from '../../embeddable/navigation_embeddable'; +import { mockNavigationEmbeddable } from '../../../common/mocks'; +import { NAV_VERTICAL_LAYOUT } from '../../../common/content_management'; +import { ExternalLinkComponent } from './external_link_component'; +import { coreServices } from '../../services/kibana_services'; +import { DEFAULT_URL_DRILLDOWN_OPTIONS } from '@kbn/ui-actions-enhanced-plugin/public'; + +describe('external link component', () => { + const defaultLinkInfo = { + destination: 'https://example.com', + order: 1, + id: 'foo', + type: 'externalLink' as const, + }; + + let navEmbeddable: NavigationEmbeddable; + beforeEach(async () => { + window.open = jest.fn(); + navEmbeddable = await mockNavigationEmbeddable({}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('by default opens in new tab', async () => { + render( + + + + ); + + const link = await screen.findByTestId('externalLink--foo'); + expect(link).toBeInTheDocument(); + await userEvent.click(link); + expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank'); + }); + + test('modified click does not trigger event.preventDefault', async () => { + const linkInfo = { + ...defaultLinkInfo, + options: { ...DEFAULT_URL_DRILLDOWN_OPTIONS, openInNewTab: false }, + }; + render( + + + + ); + const link = await screen.findByTestId('externalLink--foo'); + expect(link).toHaveTextContent('https://example.com'); + const clickEvent = createEvent.click(link, { ctrlKey: true }); + const preventDefault = jest.spyOn(clickEvent, 'preventDefault'); + fireEvent(link, clickEvent); + expect(preventDefault).toHaveBeenCalledTimes(0); + }); + + test('uses navigateToUrl when openInNewTab is false', async () => { + const linkInfo = { + ...defaultLinkInfo, + options: { ...DEFAULT_URL_DRILLDOWN_OPTIONS, openInNewTab: false }, + }; + render( + + + + ); + const link = await screen.findByTestId('externalLink--foo'); + await userEvent.click(link); + expect(coreServices.application.navigateToUrl).toBeCalledTimes(1); + expect(coreServices.application.navigateToUrl).toBeCalledWith('https://example.com'); + }); + + test('disables link when url validation fails', async () => { + const linkInfo = { + ...defaultLinkInfo, + destination: 'file://buzz', + }; + render( + + + + ); + const link = await screen.findByTestId('externalLink--foo--error'); + expect(link).toBeDisabled(); + /** + * TODO: We should test the tooltip content, but the component is disabled + * so it has pointer-events: none. This means we can not use userEvent.hover(). + * See https://testing-library.com/docs/ecosystem-user-event#pointer-events-options + */ + }); +}); diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx index cd637f6764247..1f48fe70f1620 100644 --- a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx @@ -63,10 +63,12 @@ export const ExternalLinkComponent = ({ position: layout === NAV_VERTICAL_LAYOUT ? 'right' : 'bottom', repositionOnScroll: true, delay: 'long', + 'data-test-subj': `externalLink--${link.id}--tooltip`, }} iconType={error ? 'warning' : undefined} id={`externalLink--${link.id}`} label={link.label || link.destination} + data-test-subj={error ? `externalLink--${link.id}--error` : `externalLink--${link.id}`} href={destination} onClick={async (event) => { if (!destination) return; diff --git a/src/plugins/navigation_embeddable/public/mocks.tsx b/src/plugins/navigation_embeddable/public/mocks.tsx new file mode 100644 index 0000000000000..7268893babcba --- /dev/null +++ b/src/plugins/navigation_embeddable/public/mocks.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { coreMock } from '@kbn/core/public/mocks'; +import { dashboardPluginMock } from '@kbn/dashboard-plugin/public/mocks'; +import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; +import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; +import { setKibanaServices } from './services/kibana_services'; + +export const setStubKibanaServices = () => { + const core = coreMock.createStart(); + + setKibanaServices(core, { + dashboard: dashboardPluginMock.createStartContract(), + embeddable: embeddablePluginMock.createStartContract(), + contentManagement: contentManagementMock.createStartContract(), + }); +}; diff --git a/src/plugins/navigation_embeddable/tsconfig.json b/src/plugins/navigation_embeddable/tsconfig.json index e5c172f210048..8a0b7e037928c 100644 --- a/src/plugins/navigation_embeddable/tsconfig.json +++ b/src/plugins/navigation_embeddable/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "target/types" }, - "include": ["public/**/*", "common/**/*", "server/**/*", "public/**/*.json"], + "include": ["*.ts", "public/**/*", "common/**/*", "server/**/*", "public/**/*.json"], "kbn_references": [ "@kbn/core", "@kbn/i18n", From 0a3a0119312af034ff15029d5a6e6bca33bda51a Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 21 Sep 2023 08:04:52 -0600 Subject: [PATCH 19/53] Remove `console.log` from panel placement PR --- .../component/panel_placement/place_new_panel_strategies.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_new_panel_strategies.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_new_panel_strategies.ts index 626a68b433a6a..8a8c8a83193eb 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_new_panel_strategies.ts +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_new_panel_strategies.ts @@ -16,7 +16,6 @@ export const panelPlacementStrategies = { const otherPanels = { ...currentPanels }; for (const [id, panel] of Object.entries(currentPanels)) { const currentPanel = cloneDeep(panel); - console.log('MOVING PANEL', currentPanel.explicitInput.title); currentPanel.gridData.y = currentPanel.gridData.y + height; otherPanels[id] = currentPanel; } From 2bf868ab13c6eb5aad487a2a671d38de13f016e1 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 21 Sep 2023 12:39:07 -0600 Subject: [PATCH 20/53] Update saved object hash --- .../migrations/group2/check_registered_types.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index 6633ab0680517..5b1c610def783 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -121,7 +121,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ml-module": "2225cbb4bd508ea5f69db4b848be9d8a74b60198", "ml-trained-model": "482195cefd6b04920e539d34d7356d22cb68e4f3", "monitoring-telemetry": "5d91bf75787d9d4dd2fae954d0b3f76d33d2e559", - "navigation_embeddable": "de71a127ed325261ca6bc926d93c4cd676d17a05", + "navigation_embeddable": "0019fdbbc07547035dac1720a2fe4b42711ed52b", "observability-onboarding-state": "b16064c516aac64ae699c737d7d10b6e199bfded", "osquery-manager-usage-metric": "983bcbc3b7dda0aad29b20907db233abba709bcc", "osquery-pack": "6ab4358ca4304a12dcfc1777c8135b75cffb4397", From 496612b5e9f99569e5cf4411ec2477ccd50e0cdf Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Fri, 22 Sep 2023 12:56:24 -0600 Subject: [PATCH 21/53] Fix implicit dependencies on `DashboardGridItem` in `jest_setup` --- src/plugins/dashboard/jest_setup.ts | 9 ++++++++- src/plugins/dashboard/public/mocks.tsx | 6 ------ src/plugins/dashboard/public/services/mocks.ts | 16 ++++++++++++++++ src/plugins/navigation_embeddable/jest_setup.ts | 2 +- 4 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 src/plugins/dashboard/public/services/mocks.ts diff --git a/src/plugins/dashboard/jest_setup.ts b/src/plugins/dashboard/jest_setup.ts index c6318bc3c4df6..68be9b5227e49 100644 --- a/src/plugins/dashboard/jest_setup.ts +++ b/src/plugins/dashboard/jest_setup.ts @@ -6,6 +6,13 @@ * Side Public License, v 1. */ -import { setStubDashboardServices } from './public/mocks'; +import { setStubDashboardServices } from './public/services/mocks'; +/** + * CAUTION: Be very mindful of the things you import in to this `jest_setup` file - anything that is imported + * here (either directly or implicitly through dependencies) will be **unable** to be mocked elsewhere! + * + * Refer to the "Caution" section here: + * https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options + */ setStubDashboardServices(); diff --git a/src/plugins/dashboard/public/mocks.tsx b/src/plugins/dashboard/public/mocks.tsx index 43436a42044b2..1426fa1ac14fa 100644 --- a/src/plugins/dashboard/public/mocks.tsx +++ b/src/plugins/dashboard/public/mocks.tsx @@ -12,8 +12,6 @@ import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/publ import { DashboardStart } from './plugin'; import { DashboardContainerInput, DashboardPanelState } from '../common'; import { DashboardContainer } from './dashboard_container/embeddable/dashboard_container'; -import { pluginServices } from './services/plugin_services'; -import { registry } from './services/plugin_services.stub'; export type Start = jest.Mocked; @@ -136,7 +134,3 @@ export function getSampleDashboardPanel Date: Sat, 23 Sep 2023 08:59:44 -0400 Subject: [PATCH 22/53] [Dashboard Navigation] Rename "Navigation Embeddable" to "Links" (#166963) ## Summary Renames the "Navigation Embeddable" to "Links" as the plugin name, the saved object type, and in code in the feature branch. Because of the saved object type change this will break any existing Links panels made before this PR. But this feature is still in development for 8.11 so no users will be affected. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 2 +- .i18nrc.json | 2 +- docs/developer/plugin-list.asciidoc | 8 +- package.json | 2 +- .../src/constants.ts | 2 +- .../src/kibana_migrator_utils.fixtures.ts | 2 +- .../current_mappings.json | 2 +- packages/kbn-optimizer/limits.yml | 2 +- .../group2/check_registered_types.test.ts | 2 +- .../group5/dot_kibana_split.test.ts | 2 +- src/plugins/dashboard/public/mocks.tsx | 2 +- src/plugins/links/README.md | 3 + .../common/constants.ts | 4 +- .../common/content_management/cm_services.ts | 0 .../common/content_management/index.ts | 22 ++-- .../common/content_management/latest.ts | 0 .../content_management/v1/cm_services.ts | 34 +++--- .../common/content_management/v1/constants.ts | 4 +- .../common/content_management/v1/index.ts | 20 ++-- .../common/content_management/v1/types.ts | 37 +++--- .../common/embeddable/extract.test.ts | 16 +-- .../common/embeddable/extract.ts | 8 +- .../common/embeddable/index.ts | 0 .../common/embeddable/inject.test.ts | 18 ++- .../common/embeddable/inject.ts | 10 +- .../common/embeddable/types.ts | 2 +- .../common/index.ts | 0 src/plugins/links/common/mocks.tsx | 53 +++++++++ .../common/persistable_state/index.ts | 0 .../persistable_state/references.test.ts | 0 .../common/persistable_state/references.ts | 6 +- .../common/types.ts | 2 +- .../jest.config.js | 10 +- .../jest_setup.ts | 0 .../kibana.jsonc | 6 +- .../public/_mixins.scss | 0 .../dashboard_link_component.test.tsx | 67 ++++++----- .../dashboard_link_component.tsx | 24 ++-- .../dashboard_link_destination_picker.tsx | 6 +- .../dashboard_link/dashboard_link_strings.ts | 16 +-- .../dashboard_link/dashboard_link_tools.ts | 12 +- .../components/editor/link_destination.tsx} | 14 +-- .../components/editor/link_options.tsx} | 18 +-- .../components/editor/links_editor.scss} | 24 ++-- .../components/editor/links_editor.test.tsx} | 50 ++++---- .../components/editor/links_editor.tsx} | 107 ++++++++---------- .../editor/links_editor_empty_prompt.tsx} | 16 +-- .../components/editor/links_editor_link.tsx} | 67 +++++------ .../components/editor/panel_editor_link.tsx} | 36 +++--- .../external_link_component.test.tsx | 37 +++--- .../external_link/external_link_component.tsx | 14 +-- .../external_link_destination_picker.tsx | 0 .../external_link/external_link_strings.ts | 12 +- .../external_link/external_link_tools.ts | 0 .../public/components/links_component.scss} | 16 +-- .../public/components/links_component.tsx} | 26 ++--- .../public/components/links_strings.ts} | 58 +++++----- .../public/components/tooltip_wrapper.tsx | 0 .../duplicate_title_check.ts | 6 +- .../public/content_management/index.ts | 2 +- .../links_content_management_client.ts | 67 +++++++++++ .../content_management/save_to_library.tsx | 21 ++-- .../public/editor/links_editor_tools.tsx} | 12 +- .../public/editor/open_editor_flyout.tsx | 42 +++---- .../public/editor/open_link_editor_flyout.tsx | 20 ++-- src/plugins/links/public/embeddable/index.ts | 11 ++ .../public/embeddable/links_embeddable.tsx} | 75 ++++++------ .../links_embeddable_factory.test.ts | 46 ++++++++ .../embeddable/links_embeddable_factory.ts} | 71 +++++------- .../public/embeddable/links_reducers.ts} | 15 +-- .../public/embeddable/types.ts | 56 ++++----- src/plugins/links/public/index.ts | 16 +++ .../public/mocks.tsx | 0 .../public/plugin.ts | 28 ++--- .../public/services/attribute_service.ts | 53 ++++----- .../public/services/kibana_services.ts | 7 +- .../server/content_management}/index.ts | 2 +- .../content_management/links_storage.ts} | 4 +- .../server/index.ts | 4 +- .../server/plugin.ts | 14 +-- .../server/saved_objects}/index.ts | 2 +- .../server/saved_objects/links.ts} | 2 +- .../tsconfig.json | 0 src/plugins/navigation_embeddable/README.md | 3 - .../navigation_embeddable/common/mocks.tsx | 62 ---------- .../public/assets/empty_links_dark.svg | 1 - .../public/assets/empty_links_light.svg | 1 - ...embeddable_content_management_client.ts.ts | 83 -------------- .../public/embeddable/index.ts | 11 -- .../navigation_embeddable_factory.test.ts | 52 --------- .../navigation_embeddable/public/index.ts | 16 --- tsconfig.base.json | 4 +- .../embeddable_flyout/flyout.component.tsx | 2 +- .../editor_menu/editor_menu.tsx | 2 +- yarn.lock | 8 +- 95 files changed, 787 insertions(+), 937 deletions(-) create mode 100644 src/plugins/links/README.md rename src/plugins/{navigation_embeddable => links}/common/constants.ts (78%) rename src/plugins/{navigation_embeddable => links}/common/content_management/cm_services.ts (100%) rename src/plugins/{navigation_embeddable => links}/common/content_management/index.ts (59%) rename src/plugins/{navigation_embeddable => links}/common/content_management/latest.ts (100%) rename src/plugins/{navigation_embeddable => links}/common/content_management/v1/cm_services.ts (71%) rename src/plugins/{navigation_embeddable => links}/common/content_management/v1/constants.ts (83%) rename src/plugins/{navigation_embeddable => links}/common/content_management/v1/index.ts (57%) rename src/plugins/{navigation_embeddable => links}/common/content_management/v1/types.ts (57%) rename src/plugins/{navigation_embeddable => links}/common/embeddable/extract.test.ts (80%) rename src/plugins/{navigation_embeddable => links}/common/embeddable/extract.ts (77%) rename src/plugins/{navigation_embeddable => links}/common/embeddable/index.ts (100%) rename src/plugins/{navigation_embeddable => links}/common/embeddable/inject.test.ts (79%) rename src/plugins/{navigation_embeddable => links}/common/embeddable/inject.ts (77%) rename src/plugins/{navigation_embeddable => links}/common/embeddable/types.ts (86%) rename src/plugins/{navigation_embeddable => links}/common/index.ts (100%) create mode 100644 src/plugins/links/common/mocks.tsx rename src/plugins/{navigation_embeddable => links}/common/persistable_state/index.ts (100%) rename src/plugins/{navigation_embeddable => links}/common/persistable_state/references.test.ts (100%) rename src/plugins/{navigation_embeddable => links}/common/persistable_state/references.ts (91%) rename src/plugins/{navigation_embeddable => links}/common/types.ts (91%) rename src/plugins/{navigation_embeddable => links}/jest.config.js (64%) rename src/plugins/{navigation_embeddable => links}/jest_setup.ts (100%) rename src/plugins/{navigation_embeddable => links}/kibana.jsonc (72%) rename src/plugins/{navigation_embeddable => links}/public/_mixins.scss (100%) rename src/plugins/{navigation_embeddable => links}/public/components/dashboard_link/dashboard_link_component.test.tsx (77%) rename src/plugins/{navigation_embeddable => links}/public/components/dashboard_link/dashboard_link_component.tsx (90%) rename src/plugins/{navigation_embeddable => links}/public/components/dashboard_link/dashboard_link_destination_picker.tsx (96%) rename src/plugins/{navigation_embeddable => links}/public/components/dashboard_link/dashboard_link_strings.ts (62%) rename src/plugins/{navigation_embeddable => links}/public/components/dashboard_link/dashboard_link_tools.ts (91%) rename src/plugins/{navigation_embeddable/public/components/editor/navigation_embeddable_link_destination.tsx => links/public/components/editor/link_destination.tsx} (87%) rename src/plugins/{navigation_embeddable/public/components/editor/navigation_embeddable_link_options.tsx => links/public/components/editor/link_options.tsx} (80%) rename src/plugins/{navigation_embeddable/public/components/editor/navigation_embeddable_editor.scss => links/public/components/editor/links_editor.scss} (79%) rename src/plugins/{navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.test.tsx => links/public/components/editor/links_editor.test.tsx} (64%) rename src/plugins/{navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.tsx => links/public/components/editor/links_editor.tsx} (67%) rename src/plugins/{navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx => links/public/components/editor/links_editor_empty_prompt.tsx} (65%) rename src/plugins/{navigation_embeddable/public/components/editor/navigation_embeddable_link_editor.tsx => links/public/components/editor/links_editor_link.tsx} (67%) rename src/plugins/{navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_link.tsx => links/public/components/editor/panel_editor_link.tsx} (80%) rename src/plugins/{navigation_embeddable => links}/public/components/external_link/external_link_component.test.tsx (72%) rename src/plugins/{navigation_embeddable => links}/public/components/external_link/external_link_component.tsx (89%) rename src/plugins/{navigation_embeddable => links}/public/components/external_link/external_link_destination_picker.tsx (100%) rename src/plugins/{navigation_embeddable => links}/public/components/external_link/external_link_strings.ts (69%) rename src/plugins/{navigation_embeddable => links}/public/components/external_link/external_link_tools.ts (100%) rename src/plugins/{navigation_embeddable/public/components/navigation_embeddable_component.scss => links/public/components/links_component.scss} (84%) rename src/plugins/{navigation_embeddable/public/components/navigation_embeddable_component.tsx => links/public/components/links_component.tsx} (69%) rename src/plugins/{navigation_embeddable/public/components/navigation_embeddable_strings.ts => links/public/components/links_strings.ts} (58%) rename src/plugins/{navigation_embeddable => links}/public/components/tooltip_wrapper.tsx (100%) rename src/plugins/{navigation_embeddable => links}/public/content_management/duplicate_title_check.ts (82%) rename src/plugins/{navigation_embeddable => links}/public/content_management/index.ts (80%) create mode 100644 src/plugins/links/public/content_management/links_content_management_client.ts rename src/plugins/{navigation_embeddable => links}/public/content_management/save_to_library.tsx (72%) rename src/plugins/{navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx => links/public/editor/links_editor_tools.tsx} (59%) rename src/plugins/{navigation_embeddable => links}/public/editor/open_editor_flyout.tsx (73%) rename src/plugins/{navigation_embeddable => links}/public/editor/open_link_editor_flyout.tsx (72%) create mode 100644 src/plugins/links/public/embeddable/index.ts rename src/plugins/{navigation_embeddable/public/embeddable/navigation_embeddable.tsx => links/public/embeddable/links_embeddable.tsx} (63%) create mode 100644 src/plugins/links/public/embeddable/links_embeddable_factory.test.ts rename src/plugins/{navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts => links/public/embeddable/links_embeddable_factory.ts} (62%) rename src/plugins/{navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts => links/public/embeddable/links_reducers.ts} (66%) rename src/plugins/{navigation_embeddable => links}/public/embeddable/types.ts (53%) create mode 100644 src/plugins/links/public/index.ts rename src/plugins/{navigation_embeddable => links}/public/mocks.tsx (100%) rename src/plugins/{navigation_embeddable => links}/public/plugin.ts (65%) rename src/plugins/{navigation_embeddable => links}/public/services/attribute_service.ts (57%) rename src/plugins/{navigation_embeddable => links}/public/services/kibana_services.ts (88%) rename src/plugins/{navigation_embeddable/server/saved_objects => links/server/content_management}/index.ts (81%) rename src/plugins/{navigation_embeddable/server/content_management/navigation_embeddable_storage.ts => links/server/content_management/links_storage.ts} (80%) rename src/plugins/{navigation_embeddable => links}/server/index.ts (73%) rename src/plugins/{navigation_embeddable => links}/server/plugin.ts (66%) rename src/plugins/{navigation_embeddable/server/content_management => links/server/saved_objects}/index.ts (81%) rename src/plugins/{navigation_embeddable/server/saved_objects/navigation_embeddable.ts => links/server/saved_objects/links.ts} (93%) rename src/plugins/{navigation_embeddable => links}/tsconfig.json (100%) delete mode 100644 src/plugins/navigation_embeddable/README.md delete mode 100644 src/plugins/navigation_embeddable/common/mocks.tsx delete mode 100644 src/plugins/navigation_embeddable/public/assets/empty_links_dark.svg delete mode 100644 src/plugins/navigation_embeddable/public/assets/empty_links_light.svg delete mode 100644 src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts delete mode 100644 src/plugins/navigation_embeddable/public/embeddable/index.ts delete mode 100644 src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.test.ts delete mode 100644 src/plugins/navigation_embeddable/public/index.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 89d44a44a209b..f8378b622b513 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -468,6 +468,7 @@ x-pack/plugins/lens @elastic/kibana-visualizations x-pack/plugins/license_api_guard @elastic/platform-deployment-management x-pack/plugins/license_management @elastic/platform-deployment-management x-pack/plugins/licensing @elastic/kibana-core +src/plugins/links @elastic/kibana-presentation packages/kbn-lint-packages-cli @elastic/kibana-operations packages/kbn-lint-ts-projects-cli @elastic/kibana-operations x-pack/plugins/lists @elastic/security-detection-engine @@ -524,7 +525,6 @@ x-pack/packages/ml/url_state @elastic/ml-ui packages/kbn-monaco @elastic/appex-sharedux x-pack/plugins/monitoring_collection @elastic/infra-monitoring-ui x-pack/plugins/monitoring @elastic/infra-monitoring-ui -src/plugins/navigation_embeddable @elastic/kibana-presentation src/plugins/navigation @elastic/appex-sharedux src/plugins/newsfeed @elastic/kibana-core test/common/plugins/newsfeed @elastic/kibana-core diff --git a/.i18nrc.json b/.i18nrc.json index 512f65223740e..e171beaba2903 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -82,7 +82,7 @@ ], "monaco": "packages/kbn-monaco/src", "navigation": "src/plugins/navigation", - "navigationEmbeddable": "src/plugins/navigation_embeddable", + "links": "src/plugins/links", "newsfeed": "src/plugins/newsfeed", "presentationUtil": "src/plugins/presentation_util", "randomSampling": "x-pack/packages/kbn-random-sampling", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index a89766083f2b2..f2acaee21eb90 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -238,6 +238,10 @@ in Kibana, e.g. visualizations. It has the form of a flyout panel. |Utilities for building Kibana plugins. +|{kib-repo}blob/{branch}/src/plugins/links/README.md[links] +|This plugin adds the Links panel which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard. + + |{kib-repo}blob/{branch}/src/plugins/management/README.md[management] |This plugins contains the "Stack Management" page framework. It offers navigation and an API to link individual management section into it. This plugin does not contain any individual @@ -253,10 +257,6 @@ management section itself. It also provides a stateful version of it on the start contract. -|{kib-repo}blob/{branch}/src/plugins/navigation_embeddable/README.md[navigationEmbeddable] -|This plugin adds the Navigation Embeddable which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard. - - |{kib-repo}blob/{branch}/src/plugins/newsfeed/README.md[newsfeed] |The newsfeed plugin adds a NewsfeedNavButton to the top navigation bar and renders the content in the flyout. Content is fetched from the remote (https://feeds.elastic.co) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. diff --git a/package.json b/package.json index 3e3ee69fe82f4..fa93b8c03f57a 100644 --- a/package.json +++ b/package.json @@ -493,6 +493,7 @@ "@kbn/license-api-guard-plugin": "link:x-pack/plugins/license_api_guard", "@kbn/license-management-plugin": "link:x-pack/plugins/license_management", "@kbn/licensing-plugin": "link:x-pack/plugins/licensing", + "@kbn/links-plugin": "link:src/plugins/links", "@kbn/lists-plugin": "link:x-pack/plugins/lists", "@kbn/locator-examples-plugin": "link:examples/locator_examples", "@kbn/locator-explorer-plugin": "link:examples/locator_explorer", @@ -544,7 +545,6 @@ "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/monitoring-collection-plugin": "link:x-pack/plugins/monitoring_collection", "@kbn/monitoring-plugin": "link:x-pack/plugins/monitoring", - "@kbn/navigation-embeddable-plugin": "link:src/plugins/navigation_embeddable", "@kbn/navigation-plugin": "link:src/plugins/navigation", "@kbn/newsfeed-plugin": "link:src/plugins/newsfeed", "@kbn/newsfeed-test-plugin": "link:test/common/plugins/newsfeed", diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts index 9347ce4fd7f1b..f92a2b2634b78 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts @@ -77,7 +77,7 @@ export const DEFAULT_INDEX_TYPES_MAP: IndexTypesMap = { 'ml-module', 'ml-trained-model', 'monitoring-telemetry', - 'navigation_embeddable', + 'links', 'osquery-manager-usage-metric', 'osquery-pack', 'osquery-pack-asset', diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts index 94780b9abf808..7e40132ec92fd 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts @@ -1504,7 +1504,7 @@ export const INDEX_MAP_BEFORE_SPLIT: IndexMap = { }, }, }, - navigation_embeddable: { + links: { properties: { id: { type: 'text', diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 15e656da38f16..6a8fe0a5b82ec 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1062,7 +1062,7 @@ } } }, - "navigation_embeddable": { + "links": { "dynamic": false, "properties": { "id": { diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index bde4397c30424..d4f4a74cac7f5 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -86,6 +86,7 @@ pageLoadAssetSize: lens: 38000 licenseManagement: 41817 licensing: 29004 + links: 44490 lists: 22900 logExplorer: 39045 logsShared: 281060 @@ -96,7 +97,6 @@ pageLoadAssetSize: ml: 82187 monitoring: 80000 navigation: 37269 - navigationEmbeddable: 44490 newsfeed: 42228 noDataPage: 5000 observability: 115443 diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index 5b1c610def783..d7bb9e2e9d86e 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -113,6 +113,7 @@ describe('checking migration metadata changes on all registered SO types', () => "legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8", "lens": "5cfa2c52b979b4f8df56dd13c477e152183468b9", "lens-ui-telemetry": "8c47a9e393861f76e268345ecbadfc8a5fb1e0bd", + "links": "de71a127ed325261ca6bc926d93c4cd676d17a05", "maintenance-window": "d893544460abad56ff7a0e25b78f78776dfe10d1", "map": "76c71023bd198fb6b1163b31bafd926fe2ceb9da", "metrics-data-source": "81b69dc9830699d9ead5ac8dcb9264612e2a3c89", @@ -121,7 +122,6 @@ describe('checking migration metadata changes on all registered SO types', () => "ml-module": "2225cbb4bd508ea5f69db4b848be9d8a74b60198", "ml-trained-model": "482195cefd6b04920e539d34d7356d22cb68e4f3", "monitoring-telemetry": "5d91bf75787d9d4dd2fae954d0b3f76d33d2e559", - "navigation_embeddable": "0019fdbbc07547035dac1720a2fe4b42711ed52b", "observability-onboarding-state": "b16064c516aac64ae699c737d7d10b6e199bfded", "osquery-manager-usage-metric": "983bcbc3b7dda0aad29b20907db233abba709bcc", "osquery-pack": "6ab4358ca4304a12dcfc1777c8135b75cffb4397", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index ae9a636466bae..b4edc5991d9be 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -241,7 +241,7 @@ describe('split .kibana index into multiple system indices', () => { "ml-module", "ml-trained-model", "monitoring-telemetry", - "navigation_embeddable", + "links", "observability-onboarding-state", "osquery-manager-usage-metric", "osquery-pack", diff --git a/src/plugins/dashboard/public/mocks.tsx b/src/plugins/dashboard/public/mocks.tsx index 1426fa1ac14fa..7611e12b90ae4 100644 --- a/src/plugins/dashboard/public/mocks.tsx +++ b/src/plugins/dashboard/public/mocks.tsx @@ -81,7 +81,7 @@ export function buildMockDashboard({ undefined, undefined, undefined, - { lastSavedInput: initialInput } + { lastSavedInput: initialInput, lastSavedId: savedObjectId } ); return dashboardContainer; } diff --git a/src/plugins/links/README.md b/src/plugins/links/README.md new file mode 100644 index 0000000000000..f2e37b203902b --- /dev/null +++ b/src/plugins/links/README.md @@ -0,0 +1,3 @@ +# Links panel + +This plugin adds the Links panel which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard. diff --git a/src/plugins/navigation_embeddable/common/constants.ts b/src/plugins/links/common/constants.ts similarity index 78% rename from src/plugins/navigation_embeddable/common/constants.ts rename to src/plugins/links/common/constants.ts index 9731275e04f14..eeba785bf21cd 100644 --- a/src/plugins/navigation_embeddable/common/constants.ts +++ b/src/plugins/links/common/constants.ts @@ -10,10 +10,10 @@ import { i18n } from '@kbn/i18n'; export const LATEST_VERSION = 1; -export const CONTENT_ID = 'navigation_embeddable'; +export const CONTENT_ID = 'links'; export const APP_ICON = 'link'; -export const APP_NAME = i18n.translate('navigationEmbeddable.visTypeAlias.title', { +export const APP_NAME = i18n.translate('links.visTypeAlias.title', { defaultMessage: 'Links', }); diff --git a/src/plugins/navigation_embeddable/common/content_management/cm_services.ts b/src/plugins/links/common/content_management/cm_services.ts similarity index 100% rename from src/plugins/navigation_embeddable/common/content_management/cm_services.ts rename to src/plugins/links/common/content_management/cm_services.ts diff --git a/src/plugins/navigation_embeddable/common/content_management/index.ts b/src/plugins/links/common/content_management/index.ts similarity index 59% rename from src/plugins/navigation_embeddable/common/content_management/index.ts rename to src/plugins/links/common/content_management/index.ts index 118b02d9ea1e2..e2aa69ec32e4f 100644 --- a/src/plugins/navigation_embeddable/common/content_management/index.ts +++ b/src/plugins/links/common/content_management/index.ts @@ -8,23 +8,23 @@ export { LATEST_VERSION, CONTENT_ID } from '../constants'; -export type { NavigationEmbeddableContentType } from '../types'; +export type { LinksContentType } from '../types'; export type { - NavigationLinkType, - NavigationLayoutType, - NavigationLinkOptions, - NavigationEmbeddableLink, - NavigationEmbeddableItem, - NavigationEmbeddableCrudTypes, - NavigationEmbeddableAttributes, + LinkType, + LinksLayoutType, + LinkOptions, + Link, + LinksItem, + LinksCrudTypes, + LinksAttributes, } from './latest'; export { EXTERNAL_LINK_TYPE, DASHBOARD_LINK_TYPE, - NAV_VERTICAL_LAYOUT, - NAV_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, + LINKS_HORIZONTAL_LAYOUT, } from './latest'; -export * as NavigationEmbeddableV1 from './v1'; +export * as LinksV1 from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/latest.ts b/src/plugins/links/common/content_management/latest.ts similarity index 100% rename from src/plugins/navigation_embeddable/common/content_management/latest.ts rename to src/plugins/links/common/content_management/latest.ts diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts b/src/plugins/links/common/content_management/v1/cm_services.ts similarity index 71% rename from src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts rename to src/plugins/links/common/content_management/v1/cm_services.ts index 4145f0678f16e..597fcfe0d8451 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts +++ b/src/plugins/links/common/content_management/v1/cm_services.ts @@ -16,16 +16,16 @@ import { objectTypeToGetResultSchema, } from '@kbn/content-management-utils'; import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '.'; -import { NAV_HORIZONTAL_LAYOUT, NAV_VERTICAL_LAYOUT } from './constants'; +import { LINKS_HORIZONTAL_LAYOUT, LINKS_VERTICAL_LAYOUT } from './constants'; -const baseNavigationEmbeddableLinkSchema = { +const baseLinkSchema = { id: schema.string(), label: schema.maybe(schema.string()), order: schema.number(), }; const dashboardLinkSchema = schema.object({ - ...baseNavigationEmbeddableLinkSchema, + ...baseLinkSchema, destinationRefName: schema.string(), type: schema.literal(DASHBOARD_LINK_TYPE), options: schema.maybe( @@ -41,7 +41,7 @@ const dashboardLinkSchema = schema.object({ }); const externalLinkSchema = schema.object({ - ...baseNavigationEmbeddableLinkSchema, + ...baseLinkSchema, type: schema.literal(EXTERNAL_LINK_TYPE), destination: schema.string(), options: schema.maybe( @@ -55,21 +55,19 @@ const externalLinkSchema = schema.object({ ), }); -const navigationEmbeddableAttributesSchema = schema.object( +const linksAttributesSchema = schema.object( { title: schema.string(), description: schema.maybe(schema.string()), links: schema.arrayOf(schema.oneOf([dashboardLinkSchema, externalLinkSchema])), layout: schema.maybe( - schema.oneOf([schema.literal(NAV_HORIZONTAL_LAYOUT), schema.literal(NAV_VERTICAL_LAYOUT)]) + schema.oneOf([schema.literal(LINKS_HORIZONTAL_LAYOUT), schema.literal(LINKS_VERTICAL_LAYOUT)]) ), }, { unknowns: 'forbid' } ); -const navigationEmbeddableSavedObjectSchema = savedObjectSchema( - navigationEmbeddableAttributesSchema -); +const linksSavedObjectSchema = savedObjectSchema(linksAttributesSchema); const searchOptionsSchema = schema.maybe( schema.object( @@ -80,12 +78,12 @@ const searchOptionsSchema = schema.maybe( ) ); -const navigationEmbeddableCreateOptionsSchema = schema.object({ +const linksCreateOptionsSchema = schema.object({ references: schema.maybe(createOptionsSchemas.references), overwrite: createOptionsSchemas.overwrite, }); -const navigationEmbeddableUpdateOptionsSchema = schema.object({ +const linksUpdateOptionsSchema = schema.object({ references: updateOptionsSchema.references, }); @@ -95,32 +93,32 @@ export const serviceDefinition: ServicesDefinition = { get: { out: { result: { - schema: objectTypeToGetResultSchema(navigationEmbeddableSavedObjectSchema), + schema: objectTypeToGetResultSchema(linksSavedObjectSchema), }, }, }, create: { in: { options: { - schema: navigationEmbeddableCreateOptionsSchema, + schema: linksCreateOptionsSchema, }, data: { - schema: navigationEmbeddableAttributesSchema, + schema: linksAttributesSchema, }, }, out: { result: { - schema: createResultSchema(navigationEmbeddableSavedObjectSchema), + schema: createResultSchema(linksSavedObjectSchema), }, }, }, update: { in: { options: { - schema: navigationEmbeddableUpdateOptionsSchema, // same schema as "create" + schema: linksUpdateOptionsSchema, // same schema as "create" }, data: { - schema: navigationEmbeddableAttributesSchema, + schema: linksAttributesSchema, }, }, }, @@ -134,7 +132,7 @@ export const serviceDefinition: ServicesDefinition = { mSearch: { out: { result: { - schema: navigationEmbeddableSavedObjectSchema, + schema: linksSavedObjectSchema, }, }, }, diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts b/src/plugins/links/common/content_management/v1/constants.ts similarity index 83% rename from src/plugins/navigation_embeddable/common/content_management/v1/constants.ts rename to src/plugins/links/common/content_management/v1/constants.ts index 70f1af5c0f69d..f14fdbeaf28cb 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts +++ b/src/plugins/links/common/content_management/v1/constants.ts @@ -15,5 +15,5 @@ export const EXTERNAL_LINK_TYPE = 'externalLink'; /** * Layout options */ -export const NAV_HORIZONTAL_LAYOUT = 'horizontal'; -export const NAV_VERTICAL_LAYOUT = 'vertical'; +export const LINKS_HORIZONTAL_LAYOUT = 'horizontal'; +export const LINKS_VERTICAL_LAYOUT = 'vertical'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts b/src/plugins/links/common/content_management/v1/index.ts similarity index 57% rename from src/plugins/navigation_embeddable/common/content_management/v1/index.ts rename to src/plugins/links/common/content_management/v1/index.ts index 317db65b4ebbb..65738f89ff8a6 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts +++ b/src/plugins/links/common/content_management/v1/index.ts @@ -6,19 +6,19 @@ * Side Public License, v 1. */ -import { NavigationEmbeddableCrudTypes } from './types'; +import { LinksCrudTypes } from './types'; export type { - NavigationEmbeddableCrudTypes, - NavigationEmbeddableAttributes, - NavigationEmbeddableLink, - NavigationLinkOptions, - NavigationLayoutType, - NavigationLinkType, + LinksCrudTypes, + LinksAttributes, + Link, + LinkOptions, + LinksLayoutType, + LinkType, } from './types'; -export type NavigationEmbeddableItem = NavigationEmbeddableCrudTypes['Item']; +export type LinksItem = LinksCrudTypes['Item']; export { EXTERNAL_LINK_TYPE, DASHBOARD_LINK_TYPE, - NAV_VERTICAL_LAYOUT, - NAV_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, + LINKS_HORIZONTAL_LAYOUT, } from './constants'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts b/src/plugins/links/common/content_management/v1/types.ts similarity index 57% rename from src/plugins/navigation_embeddable/common/content_management/v1/types.ts rename to src/plugins/links/common/content_management/v1/types.ts index 0e3d48d45a6d2..880bcbc67dd1d 100644 --- a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts +++ b/src/plugins/links/common/content_management/v1/types.ts @@ -14,17 +14,17 @@ import type { import { type UrlDrilldownOptions } from '@kbn/ui-actions-enhanced-plugin/public'; import { type DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; -import { NavigationEmbeddableContentType } from '../../types'; +import { LinksContentType } from '../../types'; import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE, - NAV_HORIZONTAL_LAYOUT, - NAV_VERTICAL_LAYOUT, + LINKS_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, } from './constants'; -export type NavigationEmbeddableCrudTypes = ContentManagementCrudTypes< - NavigationEmbeddableContentType, - NavigationEmbeddableAttributes, +export type LinksCrudTypes = ContentManagementCrudTypes< + LinksContentType, + LinksAttributes, Pick, Pick, { @@ -33,38 +33,35 @@ export type NavigationEmbeddableCrudTypes = ContentManagementCrudTypes< } >; -/** - * Navigation embeddable explicit input - */ -export type NavigationLinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; +export type LinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; -export type NavigationLinkOptions = DashboardDrilldownOptions | UrlDrilldownOptions; -interface BaseNavigationEmbeddableLink { +export type LinkOptions = DashboardDrilldownOptions | UrlDrilldownOptions; +interface BaseLink { id: string; label?: string; order: number; - options?: NavigationLinkOptions; + options?: LinkOptions; destination?: string; } -interface DashboardLink extends BaseNavigationEmbeddableLink { +interface DashboardLink extends BaseLink { type: typeof DASHBOARD_LINK_TYPE; destinationRefName?: string; } -interface ExternalLink extends BaseNavigationEmbeddableLink { +interface ExternalLink extends BaseLink { type: typeof EXTERNAL_LINK_TYPE; destination: string; } -export type NavigationEmbeddableLink = DashboardLink | ExternalLink; +export type Link = DashboardLink | ExternalLink; -export type NavigationLayoutType = typeof NAV_HORIZONTAL_LAYOUT | typeof NAV_VERTICAL_LAYOUT; +export type LinksLayoutType = typeof LINKS_HORIZONTAL_LAYOUT | typeof LINKS_VERTICAL_LAYOUT; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type NavigationEmbeddableAttributes = { +export type LinksAttributes = { title: string; description?: string; - links?: NavigationEmbeddableLink[]; - layout?: NavigationLayoutType; + links?: Link[]; + layout?: LinksLayoutType; }; diff --git a/src/plugins/navigation_embeddable/common/embeddable/extract.test.ts b/src/plugins/links/common/embeddable/extract.test.ts similarity index 80% rename from src/plugins/navigation_embeddable/common/embeddable/extract.test.ts rename to src/plugins/links/common/embeddable/extract.test.ts index 1fe746b722f8a..8653a3d650d70 100644 --- a/src/plugins/navigation_embeddable/common/embeddable/extract.test.ts +++ b/src/plugins/links/common/embeddable/extract.test.ts @@ -9,19 +9,19 @@ import { extract } from './extract'; test('Should return original state and empty references with by-reference embeddable state', () => { - const navigationEmbeddableByReferenceInput = { + const linksByReferenceInput = { id: '2192e502-0ec7-4316-82fb-c9bbf78525c4', - type: 'navigation_embeddable', + type: 'links', }; - expect(extract!(navigationEmbeddableByReferenceInput)).toEqual({ - state: navigationEmbeddableByReferenceInput, + expect(extract!(linksByReferenceInput)).toEqual({ + state: linksByReferenceInput, references: [], }); }); test('Should update state with refNames with by-value embeddable state', () => { - const navigationEmbeddableByValueInput = { + const linksByValueInput = { id: '8d62c3f0-c61f-4c09-ac24-9b8ee4320e20', attributes: { links: [ @@ -34,10 +34,10 @@ test('Should update state with refNames with by-value embeddable state', () => { ], layout: 'horizontal', }, - type: 'navigation_embeddable', + type: 'links', }; - expect(extract!(navigationEmbeddableByValueInput)).toEqual({ + expect(extract!(linksByValueInput)).toEqual({ references: [ { name: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard', @@ -58,7 +58,7 @@ test('Should update state with refNames with by-value embeddable state', () => { ], layout: 'horizontal', }, - type: 'navigation_embeddable', + type: 'links', }, }); }); diff --git a/src/plugins/navigation_embeddable/common/embeddable/extract.ts b/src/plugins/links/common/embeddable/extract.ts similarity index 77% rename from src/plugins/navigation_embeddable/common/embeddable/extract.ts rename to src/plugins/links/common/embeddable/extract.ts index 9d0e9c0c61b13..5fe842e4316b1 100644 --- a/src/plugins/navigation_embeddable/common/embeddable/extract.ts +++ b/src/plugins/links/common/embeddable/extract.ts @@ -7,12 +7,12 @@ */ import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; -import type { NavigationEmbeddableAttributes } from '../content_management'; +import type { LinksAttributes } from '../content_management'; import { extractReferences } from '../persistable_state'; -import { NavigationEmbeddablePersistableState } from './types'; +import { LinksPersistableState } from './types'; export const extract: EmbeddableRegistryDefinition['extract'] = (state) => { - const typedState = state as NavigationEmbeddablePersistableState; + const typedState = state as LinksPersistableState; // by-reference embeddable if (!('attributes' in typedState) || typedState.attributes === undefined) { @@ -22,7 +22,7 @@ export const extract: EmbeddableRegistryDefinition['extract'] = (state) => { // by-value embeddable const { attributes, references } = extractReferences({ - attributes: typedState.attributes as unknown as NavigationEmbeddableAttributes, + attributes: typedState.attributes as unknown as LinksAttributes, }); return { diff --git a/src/plugins/navigation_embeddable/common/embeddable/index.ts b/src/plugins/links/common/embeddable/index.ts similarity index 100% rename from src/plugins/navigation_embeddable/common/embeddable/index.ts rename to src/plugins/links/common/embeddable/index.ts diff --git a/src/plugins/navigation_embeddable/common/embeddable/inject.test.ts b/src/plugins/links/common/embeddable/inject.test.ts similarity index 79% rename from src/plugins/navigation_embeddable/common/embeddable/inject.test.ts rename to src/plugins/links/common/embeddable/inject.test.ts index bf0d9439cb811..4fdef93f8e3a9 100644 --- a/src/plugins/navigation_embeddable/common/embeddable/inject.test.ts +++ b/src/plugins/links/common/embeddable/inject.test.ts @@ -9,26 +9,24 @@ import { inject } from './inject'; test('Should return original state with by-reference embeddable state', () => { - const navigationEmbeddableByReferenceInput = { + const linksByReferenceInput = { id: 'ea40fd4e-216c-49a7-917f-f733c8a2c817', - type: 'navigation_embeddable', + type: 'links', }; const references = [ { name: 'panel_ea40fd4e-216c-49a7-917f-f733c8a2c817', - type: 'navigation_embeddable', + type: 'links', id: '7f92d7d0-8e5f-11ec-9477-312c8a6de896', }, ]; - expect(inject!(navigationEmbeddableByReferenceInput, references)).toEqual( - navigationEmbeddableByReferenceInput - ); + expect(inject!(linksByReferenceInput, references)).toEqual(linksByReferenceInput); }); test('Should inject refNames with by-value embeddable state', () => { - const navigationEmbeddableByValueInput = { + const linksByValueInput = { id: 'c3937cf9-29be-43df-a4af-a4df742d7d35', attributes: { links: [ @@ -41,7 +39,7 @@ test('Should inject refNames with by-value embeddable state', () => { ], layout: 'horizontal', }, - type: 'navigation_embeddable', + type: 'links', }; const references = [ { @@ -51,7 +49,7 @@ test('Should inject refNames with by-value embeddable state', () => { }, ]; - expect(inject!(navigationEmbeddableByValueInput, references)).toEqual({ + expect(inject!(linksByValueInput, references)).toEqual({ id: 'c3937cf9-29be-43df-a4af-a4df742d7d35', attributes: { links: [ @@ -64,6 +62,6 @@ test('Should inject refNames with by-value embeddable state', () => { ], layout: 'horizontal', }, - type: 'navigation_embeddable', + type: 'links', }); }); diff --git a/src/plugins/navigation_embeddable/common/embeddable/inject.ts b/src/plugins/links/common/embeddable/inject.ts similarity index 77% rename from src/plugins/navigation_embeddable/common/embeddable/inject.ts rename to src/plugins/links/common/embeddable/inject.ts index ccf06491407ea..134a508406361 100644 --- a/src/plugins/navigation_embeddable/common/embeddable/inject.ts +++ b/src/plugins/links/common/embeddable/inject.ts @@ -7,12 +7,12 @@ */ import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; -import { NavigationEmbeddableAttributes } from '../content_management'; +import { LinksAttributes } from '../content_management'; import { injectReferences } from '../persistable_state'; -import { NavigationEmbeddablePersistableState } from './types'; +import { LinksPersistableState } from './types'; export const inject: EmbeddableRegistryDefinition['inject'] = (state, references) => { - const typedState = state as NavigationEmbeddablePersistableState; + const typedState = state as LinksPersistableState; // by-reference embeddable if (!('attributes' in typedState) || typedState.attributes === undefined) { @@ -22,7 +22,7 @@ export const inject: EmbeddableRegistryDefinition['inject'] = (state, references // by-value embeddable try { const { attributes: attributesWithInjectedIds } = injectReferences({ - attributes: typedState.attributes as unknown as NavigationEmbeddableAttributes, + attributes: typedState.attributes as unknown as LinksAttributes, references, }); @@ -33,7 +33,7 @@ export const inject: EmbeddableRegistryDefinition['inject'] = (state, references } catch (error) { // inject exception prevents entire dashboard from display // Instead of throwing, swallow error and let dashboard display - // Errors will surface in navigation embeddable panel. + // Errors will surface in links panel. // Users can then manually edit links to resolve any problems. return typedState; } diff --git a/src/plugins/navigation_embeddable/common/embeddable/types.ts b/src/plugins/links/common/embeddable/types.ts similarity index 86% rename from src/plugins/navigation_embeddable/common/embeddable/types.ts rename to src/plugins/links/common/embeddable/types.ts index 80386fbfc1b2b..b916d34f70840 100644 --- a/src/plugins/navigation_embeddable/common/embeddable/types.ts +++ b/src/plugins/links/common/embeddable/types.ts @@ -9,6 +9,6 @@ import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; import { SerializableRecord } from '@kbn/utility-types'; -export type NavigationEmbeddablePersistableState = EmbeddableStateWithType & { +export type LinksPersistableState = EmbeddableStateWithType & { attributes: SerializableRecord; }; diff --git a/src/plugins/navigation_embeddable/common/index.ts b/src/plugins/links/common/index.ts similarity index 100% rename from src/plugins/navigation_embeddable/common/index.ts rename to src/plugins/links/common/index.ts diff --git a/src/plugins/links/common/mocks.tsx b/src/plugins/links/common/mocks.tsx new file mode 100644 index 0000000000000..299f9edcacdc4 --- /dev/null +++ b/src/plugins/links/common/mocks.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { buildMockDashboard } from '@kbn/dashboard-plugin/public/mocks'; +import { DashboardContainerInput } from '@kbn/dashboard-plugin/common'; +import { LinksByValueInput } from '../public/embeddable/types'; +import { LinksFactoryDefinition } from '../public'; +import { LinksAttributes } from './content_management'; + +jest.mock('../public/services/attribute_service', () => { + return { + getLinksAttributeService: jest.fn(() => { + return { + saveMethod: jest.fn(), + unwrapMethod: jest.fn(), + checkForDuplicateTitle: jest.fn(), + unwrapAttributes: jest.fn((attributes: LinksByValueInput) => Promise.resolve(attributes)), + wrapAttributes: jest.fn((attributes: LinksAttributes) => Promise.resolve(attributes)), + }; + }), + }; +}); + +export const mockLinksInput = (partial?: Partial): LinksByValueInput => ({ + id: 'mocked_links_panel', + attributes: { + title: 'mocked_links', + }, + ...(partial ?? {}), +}); + +export const mockLinksPanel = async ({ + explicitInput, + dashboardExplicitInput, +}: { + explicitInput?: Partial; + dashboardExplicitInput?: Partial; +}) => { + const dashboardContainer = buildMockDashboard({ + overrides: dashboardExplicitInput, + savedObjectId: '123', + }); + const linksFactoryStub = new LinksFactoryDefinition(); + + const links = await linksFactoryStub.create(mockLinksInput(explicitInput), dashboardContainer); + + return links; +}; diff --git a/src/plugins/navigation_embeddable/common/persistable_state/index.ts b/src/plugins/links/common/persistable_state/index.ts similarity index 100% rename from src/plugins/navigation_embeddable/common/persistable_state/index.ts rename to src/plugins/links/common/persistable_state/index.ts diff --git a/src/plugins/navigation_embeddable/common/persistable_state/references.test.ts b/src/plugins/links/common/persistable_state/references.test.ts similarity index 100% rename from src/plugins/navigation_embeddable/common/persistable_state/references.test.ts rename to src/plugins/links/common/persistable_state/references.test.ts diff --git a/src/plugins/navigation_embeddable/common/persistable_state/references.ts b/src/plugins/links/common/persistable_state/references.ts similarity index 91% rename from src/plugins/navigation_embeddable/common/persistable_state/references.ts rename to src/plugins/links/common/persistable_state/references.ts index 8cb0047534279..1410cdc53d234 100644 --- a/src/plugins/navigation_embeddable/common/persistable_state/references.ts +++ b/src/plugins/links/common/persistable_state/references.ts @@ -7,13 +7,13 @@ */ import { Reference } from '@kbn/content-management-utils'; -import { DASHBOARD_LINK_TYPE, NavigationEmbeddableAttributes } from '../content_management'; +import { DASHBOARD_LINK_TYPE, LinksAttributes } from '../content_management'; export function extractReferences({ attributes, references = [], }: { - attributes: NavigationEmbeddableAttributes; + attributes: LinksAttributes; references?: Reference[]; }) { if (!attributes.links) { @@ -56,7 +56,7 @@ export function injectReferences({ attributes, references, }: { - attributes: NavigationEmbeddableAttributes; + attributes: LinksAttributes; references: Reference[]; }) { if (!attributes.links) { diff --git a/src/plugins/navigation_embeddable/common/types.ts b/src/plugins/links/common/types.ts similarity index 91% rename from src/plugins/navigation_embeddable/common/types.ts rename to src/plugins/links/common/types.ts index e03b4a4dd1469..b9a42de8f9730 100644 --- a/src/plugins/navigation_embeddable/common/types.ts +++ b/src/plugins/links/common/types.ts @@ -8,7 +8,7 @@ import type { SavedObjectsResolveResponse } from '@kbn/core-saved-objects-api-server'; -export type NavigationEmbeddableContentType = 'navigation_embeddable'; +export type LinksContentType = 'links'; // TODO does this type need to be versioned? export interface SharingSavedObjectProps { diff --git a/src/plugins/navigation_embeddable/jest.config.js b/src/plugins/links/jest.config.js similarity index 64% rename from src/plugins/navigation_embeddable/jest.config.js rename to src/plugins/links/jest.config.js index 208efe01a7815..51cc1202f61aa 100644 --- a/src/plugins/navigation_embeddable/jest.config.js +++ b/src/plugins/links/jest.config.js @@ -9,11 +9,9 @@ module.exports = { preset: '@kbn/test', rootDir: '../../..', - roots: ['/src/plugins/navigation_embeddable'], - coverageDirectory: '/target/kibana-coverage/jest/src/plugins/navigation_embeddable', + roots: ['/src/plugins/links'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/links', coverageReporters: ['text', 'html'], - collectCoverageFrom: [ - '/src/plugins/navigation_embeddable/{common,public,server}/**/*.{ts,tsx}', - ], - setupFiles: ['/src/plugins/navigation_embeddable/jest_setup.ts'], + collectCoverageFrom: ['/src/plugins/links/{common,public,server}/**/*.{ts,tsx}'], + setupFiles: ['/src/plugins/links/jest_setup.ts'], }; diff --git a/src/plugins/navigation_embeddable/jest_setup.ts b/src/plugins/links/jest_setup.ts similarity index 100% rename from src/plugins/navigation_embeddable/jest_setup.ts rename to src/plugins/links/jest_setup.ts diff --git a/src/plugins/navigation_embeddable/kibana.jsonc b/src/plugins/links/kibana.jsonc similarity index 72% rename from src/plugins/navigation_embeddable/kibana.jsonc rename to src/plugins/links/kibana.jsonc index 8d90360be090e..a139bbf18a201 100644 --- a/src/plugins/navigation_embeddable/kibana.jsonc +++ b/src/plugins/links/kibana.jsonc @@ -1,10 +1,10 @@ { "type": "plugin", - "id": "@kbn/navigation-embeddable-plugin", + "id": "@kbn/links-plugin", "owner": "@elastic/kibana-presentation", - "description": "An embeddable for quickly navigating between dashboards.", + "description": "An dashboard panel for creating links to dashboards or external links.", "plugin": { - "id": "navigationEmbeddable", + "id": "links", "server": true, "browser": true, "requiredPlugins": [ diff --git a/src/plugins/navigation_embeddable/public/_mixins.scss b/src/plugins/links/public/_mixins.scss similarity index 100% rename from src/plugins/navigation_embeddable/public/_mixins.scss rename to src/plugins/links/public/_mixins.scss diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.test.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx similarity index 77% rename from src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.test.tsx rename to src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx index 7c47314c952a1..90dbdd434e2eb 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.test.tsx +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx @@ -12,12 +12,9 @@ import userEvent from '@testing-library/user-event'; import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS } from '@kbn/presentation-util-plugin/public'; import { DashboardLinkStrings } from './dashboard_link_strings'; -import { - NavigationEmbeddable, - NavigationEmbeddableContext, -} from '../../embeddable/navigation_embeddable'; -import { mockNavigationEmbeddable } from '../../../common/mocks'; -import { NAV_VERTICAL_LAYOUT } from '../../../common/content_management'; +import { LinksEmbeddable, LinksContext } from '../../embeddable/links_embeddable'; +import { mockLinksPanel } from '../../../common/mocks'; +import { LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; import { DashboardLinkComponent } from './dashboard_link_component'; import { fetchDashboard, getDashboardHref, getDashboardLocator } from './dashboard_link_tools'; import { coreServices } from '../../services/kibana_services'; @@ -57,7 +54,7 @@ describe('Dashboard link component', () => { type: 'dashboardLink' as const, }; - let navEmbeddable: NavigationEmbeddable; + let linksEmbeddable: LinksEmbeddable; beforeEach(async () => { window.open = jest.fn(); (fetchDashboard as jest.Mock).mockResolvedValue(mockDashboards[0]); @@ -67,7 +64,7 @@ describe('Dashboard link component', () => { state: {}, }); (getDashboardHref as jest.Mock).mockReturnValue('https://my-kibana.com/dashboard/123'); - navEmbeddable = await mockNavigationEmbeddable({ + linksEmbeddable = await mockLinksPanel({ dashboardExplicitInput: mockDashboards[1].attributes, }); }); @@ -78,9 +75,9 @@ describe('Dashboard link component', () => { test('by default uses navigateToApp to open in same tab', async () => { render( - - - + + + ); await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); @@ -91,7 +88,7 @@ describe('Dashboard link component', () => { ...defaultLinkInfo, options: DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, }, - navEmbeddable, + linksEmbeddable, }); const link = await screen.findByTestId('dashboardLink--foo'); @@ -106,9 +103,9 @@ describe('Dashboard link component', () => { test('modified click does not trigger event.preventDefault', async () => { render( - - - + + + ); await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); const link = await screen.findByTestId('dashboardLink--foo'); @@ -124,13 +121,13 @@ describe('Dashboard link component', () => { options: { ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, openInNewTab: true }, }; render( - - - + + + ); await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); expect(fetchDashboard).toHaveBeenCalledWith(linkInfo.destination); - expect(getDashboardLocator).toHaveBeenCalledWith({ link: linkInfo, navEmbeddable }); + expect(getDashboardLocator).toHaveBeenCalledWith({ link: linkInfo, linksEmbeddable }); const link = await screen.findByTestId('dashboardLink--foo'); expect(link).toBeInTheDocument(); await userEvent.click(link); @@ -149,12 +146,12 @@ describe('Dashboard link component', () => { }, }; render( - - - + + + ); await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); - expect(getDashboardLocator).toHaveBeenCalledWith({ link: linkInfo, navEmbeddable }); + expect(getDashboardLocator).toHaveBeenCalledWith({ link: linkInfo, linksEmbeddable }); }); test('shows an error when fetchDashboard fails', async () => { @@ -164,9 +161,9 @@ describe('Dashboard link component', () => { id: 'notfound', }; render( - - - + + + ); await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); const link = await screen.findByTestId('dashboardLink--notfound--error'); @@ -180,9 +177,9 @@ describe('Dashboard link component', () => { id: 'bar', }; render( - - - + + + ); await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); const link = await screen.findByTestId('dashboardLink--bar'); @@ -194,9 +191,9 @@ describe('Dashboard link component', () => { test('shows dashboard title and description in tooltip', async () => { render( - - - + + + ); await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); const link = await screen.findByTestId('dashboardLink--foo'); @@ -213,9 +210,9 @@ describe('Dashboard link component', () => { label, }; render( - - - + + + ); await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); const link = await screen.findByTestId('dashboardLink--foo'); diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx similarity index 90% rename from src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx rename to src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx index 1cb9df3efbdcf..f81469bb34527 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx @@ -18,27 +18,23 @@ import { import { EuiButtonEmpty, EuiListGroupItem } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { - NAV_VERTICAL_LAYOUT, - NavigationLayoutType, - NavigationEmbeddableLink, -} from '../../../common/content_management'; +import { LINKS_VERTICAL_LAYOUT, LinksLayoutType, Link } from '../../../common/content_management'; import { coreServices } from '../../services/kibana_services'; import { DashboardLinkStrings } from './dashboard_link_strings'; -import { useNavigationEmbeddable } from '../../embeddable/navigation_embeddable'; +import { useLinks } from '../../embeddable/links_embeddable'; import { fetchDashboard, getDashboardHref, getDashboardLocator } from './dashboard_link_tools'; export const DashboardLinkComponent = ({ link, layout, }: { - link: NavigationEmbeddableLink; - layout: NavigationLayoutType; + link: Link; + layout: LinksLayoutType; }) => { - const navEmbeddable = useNavigationEmbeddable(); + const linksEmbeddable = useLinks(); const [error, setError] = useState(); - const dashboardContainer = navEmbeddable.parent as DashboardContainer; + const dashboardContainer = linksEmbeddable.parent as DashboardContainer; const parentDashboardInput = useObservable(dashboardContainer.getInput$()); const parentDashboardId = dashboardContainer.select((state) => state.componentState.lastSavedId); @@ -105,7 +101,7 @@ export const DashboardLinkComponent = ({ const locator = await getDashboardLocator({ link: { ...link, options: linkOptions }, - navEmbeddable, + linksEmbeddable, }); if (!locator) return; @@ -157,7 +153,7 @@ export const DashboardLinkComponent = ({ toolTipProps={{ title: tooltipTitle, content: tooltipMessage, - position: layout === NAV_VERTICAL_LAYOUT ? 'right' : 'bottom', + position: layout === LINKS_VERTICAL_LAYOUT ? 'right' : 'bottom', repositionOnScroll: true, delay: 'long', 'data-test-subj': `dashboardLink--${link.id}--tooltip`, @@ -165,8 +161,8 @@ export const DashboardLinkComponent = ({ iconType={error ? 'warning' : undefined} iconProps={{ className: 'dashboardLinkIcon' }} isDisabled={Boolean(error) || loadingOnClickProps} - className={classNames('navigationLink', { - navigationLinkCurrent: link.destination === parentDashboardId, + className={classNames('linksPanelLink', { + linkCurrent: link.destination === parentDashboardId, dashboardLinkError: Boolean(error), 'dashboardLinkError--noLabel': !link.label, })} diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx similarity index 96% rename from src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx rename to src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx index 60467de11ed5c..626a85fc5b16d 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx @@ -50,7 +50,7 @@ export const DashboardLinkDestinationPicker = ({ key: dashboard.id, value: dashboard, label: dashboard.attributes.title, - className: 'navEmbeddableDashboardItem', + className: 'linksDashboardItem', }; }, []); @@ -106,7 +106,7 @@ export const DashboardLinkDestinationPicker = ({ {DashboardLinkStrings.getCurrentDashboardLabel()} )} - + {label} @@ -123,7 +123,7 @@ export const DashboardLinkDestinationPicker = ({ {...other} async fullWidth - className={'navEmbeddableDashboardPicker'} + className={'linksDashboardPicker'} isLoading={loadingDashboardList} aria-label={DashboardLinkStrings.getDashboardPickerAriaLabel()} placeholder={DashboardLinkStrings.getDashboardPickerPlaceholder()} diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts b/src/plugins/links/public/components/dashboard_link/dashboard_link_strings.ts similarity index 62% rename from src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts rename to src/plugins/links/public/components/dashboard_link/dashboard_link_strings.ts index ebda3bfa3763b..873a7ca51c7fe 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_strings.ts @@ -10,35 +10,35 @@ import { i18n } from '@kbn/i18n'; export const DashboardLinkStrings = { getType: () => - i18n.translate('navigationEmbeddable.dashboardLink.type', { + i18n.translate('links.dashboardLink.type', { defaultMessage: 'Dashboard link', }), getDisplayName: () => - i18n.translate('navigationEmbeddable.dashboardLink.displayName', { + i18n.translate('links.dashboardLink.displayName', { defaultMessage: 'Dashboard', }), getDescription: () => - i18n.translate('navigationEmbeddable.dsahboardLink.description', { + i18n.translate('links.dashboardLink.description', { defaultMessage: 'Go to dashboard', }), getDashboardPickerPlaceholder: () => - i18n.translate('navigationEmbeddable.dashboardLink.editor.dashboardComboBoxPlaceholder', { + i18n.translate('links.dashboardLink.editor.dashboardComboBoxPlaceholder', { defaultMessage: 'Search for a dashboard', }), getDashboardPickerAriaLabel: () => - i18n.translate('navigationEmbeddable.dashboardLink.editor.dashboardPickerAriaLabel', { + i18n.translate('links.dashboardLink.editor.dashboardPickerAriaLabel', { defaultMessage: 'Pick a destination dashboard', }), getCurrentDashboardLabel: () => - i18n.translate('navigationEmbeddable.dashboardLink.editor.currentDashboardLabel', { + i18n.translate('links.dashboardLink.editor.currentDashboardLabel', { defaultMessage: 'Current', }), getLoadingDashboardLabel: () => - i18n.translate('navigationEmbeddable.dashboardLink.editor.loadingDashboardLabel', { + i18n.translate('links.dashboardLink.editor.loadingDashboardLabel', { defaultMessage: 'Loading...', }), getDashboardErrorLabel: () => - i18n.translate('navigationEmbeddable.dashboardLink.editor.dashboardErrorLabel', { + i18n.translate('links.dashboardLink.editor.dashboardErrorLabel', { defaultMessage: 'Error fetching dashboard', }), }; diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.ts b/src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts similarity index 91% rename from src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.ts rename to src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts index a8a5eaa04b28d..eb51758bd9b68 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.ts +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts @@ -19,8 +19,8 @@ import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; import { DashboardItem } from '../../embeddable/types'; -import { NavigationEmbeddable } from '../../embeddable'; -import { NavigationEmbeddableLink } from '../../../common/content_management'; +import type { LinksEmbeddable } from '../../embeddable'; +import { Link } from '../../../common/content_management'; import { coreServices, dashboardServices } from '../../services/kibana_services'; /** @@ -104,8 +104,8 @@ export const fetchDashboards = async ({ */ interface GetDashboardLocatorProps { - link: NavigationEmbeddableLink & { options: DashboardDrilldownOptions }; - navEmbeddable: NavigationEmbeddable; + link: Link & { options: DashboardDrilldownOptions }; + linksEmbeddable: LinksEmbeddable; } /** @@ -113,10 +113,10 @@ interface GetDashboardLocatorProps { * @param props `GetDashboardLocatorProps` * @returns The locator to use for dashboard navigation */ -export const getDashboardLocator = async ({ link, navEmbeddable }: GetDashboardLocatorProps) => { +export const getDashboardLocator = async ({ link, linksEmbeddable }: GetDashboardLocatorProps) => { const params: DashboardAppLocatorParams = { dashboardId: link.destination, - ...getEmbeddableParams(navEmbeddable, link.options), + ...getEmbeddableParams(linksEmbeddable, link.options), }; const locator = dashboardServices.locator; // TODO: Make this generic as part of https://github.com/elastic/kibana/issues/164748 diff --git a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_link_destination.tsx b/src/plugins/links/public/components/editor/link_destination.tsx similarity index 87% rename from src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_link_destination.tsx rename to src/plugins/links/public/components/editor/link_destination.tsx index dd75accac4b8e..ff6b89606c438 100644 --- a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_link_destination.tsx +++ b/src/plugins/links/public/components/editor/link_destination.tsx @@ -12,24 +12,24 @@ import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_conta import { EuiFormRow } from '@elastic/eui'; import { - NavigationLinkType, + LinkType, EXTERNAL_LINK_TYPE, DASHBOARD_LINK_TYPE, } from '../../../common/content_management'; -import { NavigationEmbeddableUnorderedLink } from '../../editor/open_link_editor_flyout'; +import { UnorderedLink } from '../../editor/open_link_editor_flyout'; import { ExternalLinkDestinationPicker } from '../external_link/external_link_destination_picker'; import { DashboardLinkDestinationPicker } from '../dashboard_link/dashboard_link_destination_picker'; -import { NavEmbeddableStrings } from '../navigation_embeddable_strings'; +import { LinksStrings } from '../links_strings'; -export const NavigationEmbeddableLinkDestination = ({ +export const LinkDestination = ({ link, setDestination, parentDashboard, selectedLinkType, }: { - selectedLinkType: NavigationLinkType; + selectedLinkType: LinkType; parentDashboard?: DashboardContainer; - link?: NavigationEmbeddableUnorderedLink; + link?: UnorderedLink; setDestination: (destination?: string, defaultLabel?: string) => void; }) => { const [destinationError, setDestinationError] = useState(); @@ -49,7 +49,7 @@ export const NavigationEmbeddableLinkDestination = ({ {selectedLinkType === DASHBOARD_LINK_TYPE ? ( void; + selectedLinkType: LinkType; + link?: UnorderedLink; + setLinkOptions: (options: LinkOptions) => void; }) => { const [dashboardLinkOptions, setDashboardLinkOptions] = useState({ ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, @@ -48,7 +48,7 @@ export const NavigationEmbeddableLinkOptions = ({ }); return ( - + {selectedLinkType === DASHBOARD_LINK_TYPE ? ( { @@ -33,7 +33,7 @@ jest.mock('../dashboard_link/dashboard_link_tools', () => { }; }); -describe('NavigationEmbeddablePanelEditor', () => { +describe('LinksEditor', () => { const defaultProps = { onSaveToLibrary: jest.fn().mockImplementation(() => Promise.resolve()), onAddToDashboard: jest.fn(), @@ -41,7 +41,7 @@ describe('NavigationEmbeddablePanelEditor', () => { isByReference: false, }; - const someLinks: NavigationEmbeddableLink[] = [ + const someLinks: Link[] = [ { id: 'foo', type: 'dashboardLink' as const, @@ -73,25 +73,25 @@ describe('NavigationEmbeddablePanelEditor', () => { }); test('shows empty state with no links', async () => { - render(); - expect(screen.getByTestId('navEmbeddable--panelEditor--title')).toHaveTextContent( - NavEmbeddableStrings.editor.panelEditor.getCreateFlyoutTitle() + render(); + expect(screen.getByTestId('links--panelEditor--title')).toHaveTextContent( + LinksStrings.editor.panelEditor.getCreateFlyoutTitle() ); - expect(screen.getByTestId('navEmbeddable--panelEditor--emptyPrompt')).toBeInTheDocument(); - expect(screen.getByTestId('navEmbeddable--panelEditor--saveBtn')).toBeDisabled(); + expect(screen.getByTestId('links--panelEditor--emptyPrompt')).toBeInTheDocument(); + expect(screen.getByTestId('links--panelEditor--saveBtn')).toBeDisabled(); - await userEvent.click(screen.getByTestId('navEmbeddable--panelEditor--closeBtn')); + await userEvent.click(screen.getByTestId('links--panelEditor--closeBtn')); expect(defaultProps.onClose).toHaveBeenCalledTimes(1); }); test('shows links in order', async () => { const expectedLinkIds = [...someLinks].sort((a, b) => a.order - b.order).map(({ id }) => id); - render(); + render(); await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); - expect(screen.getByTestId('navEmbeddable--panelEditor--title')).toHaveTextContent( - NavEmbeddableStrings.editor.panelEditor.getEditFlyoutTitle() + expect(screen.getByTestId('links--panelEditor--title')).toHaveTextContent( + LinksStrings.editor.panelEditor.getEditFlyoutTitle() ); - const draggableLinks = screen.getAllByTestId('navEmbeddable--panelEditor--draggableLink'); + const draggableLinks = screen.getAllByTestId('links--panelEditor--draggableLink'); expect(draggableLinks.length).toEqual(4); draggableLinks.forEach((link, idx) => { @@ -101,29 +101,21 @@ describe('NavigationEmbeddablePanelEditor', () => { test('saving by reference panels calls onSaveToLibrary', async () => { const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order); - render( - - ); + render(); await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); - const saveButton = screen.getByTestId('navEmbeddable--panelEditor--saveBtn'); + const saveButton = screen.getByTestId('links--panelEditor--saveBtn'); await userEvent.click(saveButton); await waitFor(() => expect(defaultProps.onSaveToLibrary).toHaveBeenCalledTimes(1)); - expect(defaultProps.onSaveToLibrary).toHaveBeenCalledWith(orderedLinks, NAV_VERTICAL_LAYOUT); + expect(defaultProps.onSaveToLibrary).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT); }); test('saving by value panel calls onAddToDashboard', async () => { const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order); - render( - - ); + render(); await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); - const saveButton = screen.getByTestId('navEmbeddable--panelEditor--saveBtn'); + const saveButton = screen.getByTestId('links--panelEditor--saveBtn'); await userEvent.click(saveButton); expect(defaultProps.onAddToDashboard).toHaveBeenCalledTimes(1); - expect(defaultProps.onAddToDashboard).toHaveBeenCalledWith(orderedLinks, NAV_VERTICAL_LAYOUT); + expect(defaultProps.onAddToDashboard).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT); }); }); diff --git a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.tsx b/src/plugins/links/public/components/editor/links_editor.tsx similarity index 67% rename from src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.tsx rename to src/plugins/links/public/components/editor/links_editor.tsx index 3028c22fa48b3..a2a1b409b6cf5 100644 --- a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor.tsx +++ b/src/plugins/links/public/components/editor/links_editor.tsx @@ -29,36 +29,36 @@ import { } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { NavigationLayoutInfo } from '../../embeddable/types'; +import { LinksLayoutInfo } from '../../embeddable/types'; import { - NavigationEmbeddableLink, - NavigationLayoutType, - NAV_HORIZONTAL_LAYOUT, - NAV_VERTICAL_LAYOUT, + Link, + LinksLayoutType, + LINKS_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, } from '../../../common/content_management'; import { coreServices } from '../../services/kibana_services'; -import { NavEmbeddableStrings } from '../navigation_embeddable_strings'; +import { LinksStrings } from '../links_strings'; import { openLinkEditorFlyout } from '../../editor/open_link_editor_flyout'; -import { memoizedGetOrderedLinkList } from '../../editor/navigation_embeddable_editor_tools'; -import { NavigationEmbeddablePanelEditorLink } from './navigation_embeddable_panel_editor_link'; -import { NavigationEmbeddablePanelEditorEmptyPrompt } from './navigation_embeddable_panel_editor_empty_prompt'; +import { memoizedGetOrderedLinkList } from '../../editor/links_editor_tools'; +import { PanelEditorLink } from './panel_editor_link'; +import { LinksEditorEmptyPrompt } from './links_editor_empty_prompt'; import { TooltipWrapper } from '../tooltip_wrapper'; -import './navigation_embeddable_editor.scss'; +import './links_editor.scss'; const layoutOptions: EuiButtonGroupOptionProps[] = [ { - id: NAV_VERTICAL_LAYOUT, - label: NavigationLayoutInfo[NAV_VERTICAL_LAYOUT].displayName, + id: LINKS_VERTICAL_LAYOUT, + label: LinksLayoutInfo[LINKS_VERTICAL_LAYOUT].displayName, }, { - id: NAV_HORIZONTAL_LAYOUT, - label: NavigationLayoutInfo[NAV_HORIZONTAL_LAYOUT].displayName, + id: LINKS_HORIZONTAL_LAYOUT, + label: LinksLayoutInfo[LINKS_HORIZONTAL_LAYOUT].displayName, }, ]; -const NavigationEmbeddablePanelEditor = ({ +const LinksEditor = ({ onSaveToLibrary, onAddToDashboard, onClose, @@ -67,25 +67,22 @@ const NavigationEmbeddablePanelEditor = ({ parentDashboard, isByReference, }: { - onSaveToLibrary: ( - newLinks: NavigationEmbeddableLink[], - newLayout: NavigationLayoutType - ) => Promise; - onAddToDashboard: (newLinks: NavigationEmbeddableLink[], newLayout: NavigationLayoutType) => void; + onSaveToLibrary: (newLinks: Link[], newLayout: LinksLayoutType) => Promise; + onAddToDashboard: (newLinks: Link[], newLayout: LinksLayoutType) => void; onClose: () => void; - initialLinks?: NavigationEmbeddableLink[]; - initialLayout?: NavigationLayoutType; + initialLinks?: Link[]; + initialLayout?: LinksLayoutType; parentDashboard?: DashboardContainer; isByReference: boolean; }) => { const toasts = coreServices.notifications.toasts; const editLinkFlyoutRef: React.RefObject = useMemo(() => React.createRef(), []); - const [currentLayout, setCurrentLayout] = useState( - initialLayout ?? NAV_VERTICAL_LAYOUT + const [currentLayout, setCurrentLayout] = useState( + initialLayout ?? LINKS_VERTICAL_LAYOUT ); const [isSaving, setIsSaving] = useState(false); - const [orderedLinks, setOrderedLinks] = useState([]); + const [orderedLinks, setOrderedLinks] = useState([]); const [saveByReference, setSaveByReference] = useState(!initialLinks ? true : isByReference); const isEditingExisting = initialLinks || isByReference; @@ -113,7 +110,7 @@ const NavigationEmbeddablePanelEditor = ({ ); const addOrEditLink = useCallback( - async (linkToEdit?: NavigationEmbeddableLink) => { + async (linkToEdit?: Link) => { const newLink = await openLinkEditorFlyout({ parentDashboard, link: linkToEdit, @@ -124,16 +121,13 @@ const NavigationEmbeddablePanelEditor = ({ setOrderedLinks( orderedLinks.map((link) => { if (link.id === linkToEdit.id) { - return { ...newLink, order: linkToEdit.order } as NavigationEmbeddableLink; + return { ...newLink, order: linkToEdit.order } as Link; } return link; }) ); } else { - setOrderedLinks([ - ...orderedLinks, - { ...newLink, order: orderedLinks.length } as NavigationEmbeddableLink, - ]); + setOrderedLinks([...orderedLinks, { ...newLink, order: orderedLinks.length } as Link]); } } }, @@ -159,39 +153,39 @@ const NavigationEmbeddablePanelEditor = ({ <>
- +

{isEditingExisting - ? NavEmbeddableStrings.editor.panelEditor.getEditFlyoutTitle() - : NavEmbeddableStrings.editor.panelEditor.getCreateFlyoutTitle()} + ? LinksStrings.editor.panelEditor.getEditFlyoutTitle() + : LinksStrings.editor.panelEditor.getCreateFlyoutTitle()}

- + { - setCurrentLayout(id as NavigationLayoutType); + setCurrentLayout(id as LinksLayoutType); }} - legend={NavEmbeddableStrings.editor.panelEditor.getLayoutSettingsLegend()} + legend={LinksStrings.editor.panelEditor.getLayoutSettingsLegend()} /> - + {/* Needs to be surrounded by a div rather than a fragment so the EuiFormRow can respond to the focus of the inner elements */}
{hasZeroLinks ? ( - addOrEditLink()} /> + addOrEditLink()} /> ) : ( <> {orderedLinks.map((link, idx) => ( {(provided) => ( - addOrEditLink(link)} @@ -222,7 +216,7 @@ const NavigationEmbeddablePanelEditor = ({ iconType="plusInCircle" onClick={() => addOrEditLink()} > - {NavEmbeddableStrings.editor.getAddButtonLabel()} + {LinksStrings.editor.getAddButtonLabel()} )} @@ -237,9 +231,9 @@ const NavigationEmbeddablePanelEditor = ({ onClick={onClose} iconType="cross" flush="left" - data-test-subj="navEmbeddable--panelEditor--closeBtn" + data-test-subj="links--panelEditor--closeBtn" > - {NavEmbeddableStrings.editor.getCancelButtonLabel()} + {LinksStrings.editor.getCancelButtonLabel()} @@ -248,15 +242,15 @@ const NavigationEmbeddablePanelEditor = ({ setSaveByReference(!saveByReference)} - data-test-subj="navEmbeddable--panelEditor--saveByReferenceSwitch" + data-test-subj="links--panelEditor--saveByReferenceSwitch" /> @@ -264,22 +258,21 @@ const NavigationEmbeddablePanelEditor = ({ { if (saveByReference) { setIsSaving(true); onSaveToLibrary(orderedLinks, currentLayout) .catch((e) => { toasts.addError(e, { - title: - NavEmbeddableStrings.editor.panelEditor.getErrorDuringSaveToastTitle(), + title: LinksStrings.editor.panelEditor.getErrorDuringSaveToastTitle(), }); }) .finally(() => { @@ -290,7 +283,7 @@ const NavigationEmbeddablePanelEditor = ({ } }} > - {NavEmbeddableStrings.editor.panelEditor.getSaveButtonLabel()} + {LinksStrings.editor.panelEditor.getSaveButtonLabel()} @@ -304,4 +297,4 @@ const NavigationEmbeddablePanelEditor = ({ // required for dynamic import using React.lazy() // eslint-disable-next-line import/no-default-export -export default NavigationEmbeddablePanelEditor; +export default LinksEditor; diff --git a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx b/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx similarity index 65% rename from src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx rename to src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx index 1fd18f4730837..ffb4527e1968f 100644 --- a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_empty_prompt.tsx +++ b/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx @@ -10,15 +10,11 @@ import React from 'react'; import { EuiText, EuiPanel, EuiSpacer, EuiButton, EuiEmptyPrompt, EuiFormRow } from '@elastic/eui'; -import { NavEmbeddableStrings } from '../navigation_embeddable_strings'; +import { LinksStrings } from '../links_strings'; -export const NavigationEmbeddablePanelEditorEmptyPrompt = ({ - addLink, -}: { - addLink: () => Promise; -}) => { +export const LinksEditorEmptyPrompt = ({ addLink }: { addLink: () => Promise }) => { return ( - + - - {NavEmbeddableStrings.editor.panelEditor.getEmptyLinksMessage()} - + {LinksStrings.editor.panelEditor.getEmptyLinksMessage()} - {NavEmbeddableStrings.editor.getAddButtonLabel()} + {LinksStrings.editor.getAddButtonLabel()} } diff --git a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_link_editor.tsx b/src/plugins/links/public/components/editor/links_editor_link.tsx similarity index 67% rename from src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_link_editor.tsx rename to src/plugins/links/public/components/editor/links_editor_link.tsx index 502e7091fa866..43af210f6aacc 100644 --- a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_link_editor.tsx +++ b/src/plugins/links/public/components/editor/links_editor_link.tsx @@ -29,19 +29,19 @@ import { import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { - NavigationLinkType, + LinkType, EXTERNAL_LINK_TYPE, DASHBOARD_LINK_TYPE, - NavigationLinkOptions, - NavigationEmbeddableLink, + LinkOptions, + Link, } from '../../../common/content_management'; -import { NavigationLinkInfo } from '../../embeddable/types'; -import { NavEmbeddableStrings } from '../navigation_embeddable_strings'; -import { NavigationEmbeddableUnorderedLink } from '../../editor/open_link_editor_flyout'; -import { NavigationEmbeddableLinkOptions } from './navigation_embeddable_link_options'; -import { NavigationEmbeddableLinkDestination } from './navigation_embeddable_link_destination'; +import { LinkInfo } from '../../embeddable/types'; +import { LinksStrings } from '../links_strings'; +import { UnorderedLink } from '../../editor/open_link_editor_flyout'; +import { LinkOptionsComponent } from './link_options'; +import { LinkDestination } from './link_destination'; -export const NavigationEmbeddableLinkEditor = ({ +export const LinkEditor = ({ link, onSave, onClose, @@ -49,31 +49,27 @@ export const NavigationEmbeddableLinkEditor = ({ }: { onClose: () => void; parentDashboard?: DashboardContainer; - link?: NavigationEmbeddableUnorderedLink; // will only be defined if **editing** a link; otherwise, creating a new link - onSave: (newLink: Omit) => void; + link?: UnorderedLink; // will only be defined if **editing** a link; otherwise, creating a new link + onSave: (newLink: Omit) => void; }) => { - const [selectedLinkType, setSelectedLinkType] = useState( + const [selectedLinkType, setSelectedLinkType] = useState( link?.type ?? DASHBOARD_LINK_TYPE ); const [defaultLinkLabel, setDefaultLinkLabel] = useState(); const [currentLinkLabel, setCurrentLinkLabel] = useState(link?.label ?? ''); - const [linkOptions, setLinkOptions] = useState(); + const [linkOptions, setLinkOptions] = useState(); const [linkDestination, setLinkDestination] = useState(link?.destination); const linkTypes: EuiRadioGroupOption[] = useMemo(() => { - return ([DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE] as NavigationLinkType[]).map((type) => { + return ([DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE] as LinkType[]).map((type) => { return { id: type, label: ( - + - + - {NavigationLinkInfo[type].displayName} + {LinkInfo[type].displayName} ), }; @@ -93,7 +89,7 @@ export const NavigationEmbeddableLinkEditor = ({ ); return ( - + onClose()} > - +

{link - ? NavEmbeddableStrings.editor.getEditLinkTitle() - : NavEmbeddableStrings.editor.getAddButtonLabel()} + ? LinksStrings.editor.getEditLinkTitle() + : LinksStrings.editor.getAddButtonLabel()}

- + - - + setCurrentLinkLabel(e.target.value)} /> - onClose()} iconType="cross"> - {NavEmbeddableStrings.editor.getCancelButtonLabel()} + {LinksStrings.editor.getCancelButtonLabel()} @@ -177,8 +170,8 @@ export const NavigationEmbeddableLinkEditor = ({ }} > {link - ? NavEmbeddableStrings.editor.getUpdateButtonLabel() - : NavEmbeddableStrings.editor.getAddButtonLabel()} + ? LinksStrings.editor.getUpdateButtonLabel() + : LinksStrings.editor.getAddButtonLabel()}
diff --git a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_link.tsx b/src/plugins/links/public/components/editor/panel_editor_link.tsx similarity index 80% rename from src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_link.tsx rename to src/plugins/links/public/components/editor/panel_editor_link.tsx index 4d1fdf2636adc..f363e17ec0da4 100644 --- a/src/plugins/navigation_embeddable/public/components/editor/navigation_embeddable_panel_editor_link.tsx +++ b/src/plugins/links/public/components/editor/panel_editor_link.tsx @@ -23,14 +23,14 @@ import { } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { NavigationLinkInfo } from '../../embeddable/types'; +import { LinkInfo } from '../../embeddable/types'; import { validateUrl } from '../external_link/external_link_tools'; import { fetchDashboard } from '../dashboard_link/dashboard_link_tools'; -import { NavEmbeddableStrings } from '../navigation_embeddable_strings'; +import { LinksStrings } from '../links_strings'; import { DashboardLinkStrings } from '../dashboard_link/dashboard_link_strings'; -import { DASHBOARD_LINK_TYPE, NavigationEmbeddableLink } from '../../../common/content_management'; +import { DASHBOARD_LINK_TYPE, Link } from '../../../common/content_management'; -export const NavigationEmbeddablePanelEditorLink = ({ +export const PanelEditorLink = ({ link, editLink, deleteLink, @@ -39,7 +39,7 @@ export const NavigationEmbeddablePanelEditorLink = ({ }: { editLink: () => void; deleteLink: () => void; - link: NavigationEmbeddableLink; + link: Link; parentDashboard?: DashboardContainer; dragHandleProps?: DraggableProvidedDragHandleProps; }) => { @@ -82,19 +82,19 @@ export const NavigationEmbeddablePanelEditorLink = ({ @@ -142,33 +142,33 @@ export const NavigationEmbeddablePanelEditorLink = ({ color="transparent" paddingSize="none" {...dragHandleProps} - aria-label={NavEmbeddableStrings.editor.panelEditor.getDragHandleAriaLabel()} + aria-label={LinksStrings.editor.panelEditor.getDragHandleAriaLabel()} >
- + - + - + - + diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.test.tsx b/src/plugins/links/public/components/external_link/external_link_component.test.tsx similarity index 72% rename from src/plugins/navigation_embeddable/public/components/external_link/external_link_component.test.tsx rename to src/plugins/links/public/components/external_link/external_link_component.test.tsx index 1945c9e762a7d..1afdc17c43563 100644 --- a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.test.tsx +++ b/src/plugins/links/public/components/external_link/external_link_component.test.tsx @@ -10,12 +10,9 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import { createEvent, fireEvent, render, screen } from '@testing-library/react'; -import { - NavigationEmbeddable, - NavigationEmbeddableContext, -} from '../../embeddable/navigation_embeddable'; -import { mockNavigationEmbeddable } from '../../../common/mocks'; -import { NAV_VERTICAL_LAYOUT } from '../../../common/content_management'; +import { LinksEmbeddable, LinksContext } from '../../embeddable/links_embeddable'; +import { mockLinksPanel } from '../../../common/mocks'; +import { LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; import { ExternalLinkComponent } from './external_link_component'; import { coreServices } from '../../services/kibana_services'; import { DEFAULT_URL_DRILLDOWN_OPTIONS } from '@kbn/ui-actions-enhanced-plugin/public'; @@ -28,10 +25,10 @@ describe('external link component', () => { type: 'externalLink' as const, }; - let navEmbeddable: NavigationEmbeddable; + let links: LinksEmbeddable; beforeEach(async () => { window.open = jest.fn(); - navEmbeddable = await mockNavigationEmbeddable({}); + links = await mockLinksPanel({}); }); afterEach(() => { @@ -40,9 +37,9 @@ describe('external link component', () => { test('by default opens in new tab', async () => { render( - - - + + + ); const link = await screen.findByTestId('externalLink--foo'); @@ -57,9 +54,9 @@ describe('external link component', () => { options: { ...DEFAULT_URL_DRILLDOWN_OPTIONS, openInNewTab: false }, }; render( - - - + + + ); const link = await screen.findByTestId('externalLink--foo'); expect(link).toHaveTextContent('https://example.com'); @@ -75,9 +72,9 @@ describe('external link component', () => { options: { ...DEFAULT_URL_DRILLDOWN_OPTIONS, openInNewTab: false }, }; render( - - - + + + ); const link = await screen.findByTestId('externalLink--foo'); await userEvent.click(link); @@ -91,9 +88,9 @@ describe('external link component', () => { destination: 'file://buzz', }; render( - - - + + + ); const link = await screen.findByTestId('externalLink--foo--error'); expect(link).toBeDisabled(); diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx b/src/plugins/links/public/components/external_link/external_link_component.tsx similarity index 89% rename from src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx rename to src/plugins/links/public/components/external_link/external_link_component.tsx index 1f48fe70f1620..c2fee4194c6a8 100644 --- a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx +++ b/src/plugins/links/public/components/external_link/external_link_component.tsx @@ -16,18 +16,14 @@ import { EuiListGroupItem } from '@elastic/eui'; import { validateUrl } from './external_link_tools'; import { coreServices } from '../../services/kibana_services'; -import { - NavigationEmbeddableLink, - NavigationLayoutType, - NAV_VERTICAL_LAYOUT, -} from '../../../common/content_management'; +import { Link, LinksLayoutType, LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; export const ExternalLinkComponent = ({ link, layout, }: { - link: NavigationEmbeddableLink; - layout: NavigationLayoutType; + link: Link; + layout: LinksLayoutType; }) => { const [error, setError] = useState(); @@ -56,11 +52,11 @@ export const ExternalLinkComponent = ({ size="s" color="text" isDisabled={!link.destination || !isValidUrl} - className={'navigationLink'} + className={'linksPanelLink'} showToolTip={!isValidUrl} toolTipProps={{ content: error, - position: layout === NAV_VERTICAL_LAYOUT ? 'right' : 'bottom', + position: layout === LINKS_VERTICAL_LAYOUT ? 'right' : 'bottom', repositionOnScroll: true, delay: 'long', 'data-test-subj': `externalLink--${link.id}--tooltip`, diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx b/src/plugins/links/public/components/external_link/external_link_destination_picker.tsx similarity index 100% rename from src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx rename to src/plugins/links/public/components/external_link/external_link_destination_picker.tsx diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts b/src/plugins/links/public/components/external_link/external_link_strings.ts similarity index 69% rename from src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts rename to src/plugins/links/public/components/external_link/external_link_strings.ts index 7d6667a51b0f3..e2460347afea8 100644 --- a/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts +++ b/src/plugins/links/public/components/external_link/external_link_strings.ts @@ -10,30 +10,30 @@ import { i18n } from '@kbn/i18n'; export const ExternalLinkStrings = { getType: () => - i18n.translate('navigationEmbeddable.externalLink.type', { + i18n.translate('links.externalLink.type', { defaultMessage: 'External URL', }), getDisplayName: () => - i18n.translate('navigationEmbeddable.externalLink.displayName', { + i18n.translate('links.externalLink.displayName', { defaultMessage: 'URL', }), getDescription: () => - i18n.translate('navigationEmbeddable.externalLink.description', { + i18n.translate('links.externalLink.description', { defaultMessage: 'Go to URL', }), getPlaceholder: () => - i18n.translate('navigationEmbeddable.externalLink.editor.placeholder', { + i18n.translate('links.externalLink.editor.placeholder', { defaultMessage: 'Enter external URL', }), getUrlFormatError: () => - i18n.translate('navigationEmbeddable.externalLink.editor.urlFormatError', { + i18n.translate('links.externalLink.editor.urlFormatError', { defaultMessage: 'Invalid format. Example: {exampleUrl}', values: { exampleUrl: 'https://elastic.co/', }, }), getDisallowedUrlError: () => - i18n.translate('navigationEmbeddable.externalLink.editor.disallowedUrlError', { + i18n.translate('links.externalLink.editor.disallowedUrlError', { defaultMessage: 'This URL is not allowed by your administrator. Refer to "externalUrl.policy" configuration.', }), diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_tools.ts b/src/plugins/links/public/components/external_link/external_link_tools.ts similarity index 100% rename from src/plugins/navigation_embeddable/public/components/external_link/external_link_tools.ts rename to src/plugins/links/public/components/external_link/external_link_tools.ts diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.scss b/src/plugins/links/public/components/links_component.scss similarity index 84% rename from src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.scss rename to src/plugins/links/public/components/links_component.scss index bbc51f041efec..ecd801492b9e4 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.scss +++ b/src/plugins/links/public/components/links_component.scss @@ -1,6 +1,6 @@ -.navEmbeddableComponent { +.linksComponent { - .navigationLink { + .linksPanelLink { max-width: fit-content; // added this so that the error tooltip shows up **right beside** the link label &.dashboardLinkError { @@ -13,7 +13,7 @@ } } - &.navigationLinkCurrent { + &.linkCurrent { border-radius: 0; .euiListGroupItem__text { cursor: default; @@ -24,8 +24,8 @@ .verticalLayoutWrapper { gap: $euiSizeXS; - .navigationLink { - &.navigationLinkCurrent { + .linksPanelLink { + &.linkCurrent { &::before { content: ''; position: absolute; @@ -44,8 +44,8 @@ align-items: center; flex-direction: row; - .navigationLink { - &.navigationLinkCurrent { + .linksPanelLink { + &.linkCurrent { padding: 0 $euiSizeS; .euiListGroupItem__text { @@ -55,4 +55,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx b/src/plugins/links/public/components/links_component.tsx similarity index 69% rename from src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx rename to src/plugins/links/public/components/links_component.tsx index 7a337426dba71..c98fce1fcdaf9 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx +++ b/src/plugins/links/public/components/links_component.tsx @@ -8,22 +8,22 @@ import React, { useMemo } from 'react'; import { EuiListGroup, EuiPanel } from '@elastic/eui'; -import { useNavigationEmbeddable } from '../embeddable/navigation_embeddable'; +import { useLinks } from '../embeddable/links_embeddable'; import { ExternalLinkComponent } from './external_link/external_link_component'; import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; -import { memoizedGetOrderedLinkList } from '../editor/navigation_embeddable_editor_tools'; +import { memoizedGetOrderedLinkList } from '../editor/links_editor_tools'; import { DASHBOARD_LINK_TYPE, - NAV_HORIZONTAL_LAYOUT, - NAV_VERTICAL_LAYOUT, + LINKS_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, } from '../../common/content_management'; -import './navigation_embeddable_component.scss'; +import './links_component.scss'; -export const NavigationEmbeddableComponent = () => { - const navEmbeddable = useNavigationEmbeddable(); - const links = navEmbeddable.select((state) => state.componentState.links); - const layout = navEmbeddable.select((state) => state.componentState.layout); +export const LinksComponent = () => { + const linksEmbeddable = useLinks(); + const links = linksEmbeddable.select((state) => state.componentState.links); + const layout = linksEmbeddable.select((state) => state.componentState.layout); const orderedLinks = useMemo(() => { if (!links) return []; @@ -41,13 +41,13 @@ export const NavigationEmbeddableComponent = () => { ) : ( ), }, @@ -57,8 +57,8 @@ export const NavigationEmbeddableComponent = () => { return ( diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts b/src/plugins/links/public/components/links_strings.ts similarity index 58% rename from src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts rename to src/plugins/links/public/components/links_strings.ts index 7723eb1e5d8c0..50341f9fa6a8a 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts +++ b/src/plugins/links/public/components/links_strings.ts @@ -8,122 +8,122 @@ import { i18n } from '@kbn/i18n'; -export const NavEmbeddableStrings = { +export const LinksStrings = { getDescription: () => - i18n.translate('navigationEmbeddable.description', { + i18n.translate('links.description', { defaultMessage: 'Use links to navigate to commonly used dashboards and websites.', }), editor: { getAddButtonLabel: () => - i18n.translate('navigationEmbeddable.editor.addButtonLabel', { + i18n.translate('links.editor.addButtonLabel', { defaultMessage: 'Add link', }), getUpdateButtonLabel: () => - i18n.translate('navigationEmbeddable.editor.updateButtonLabel', { + i18n.translate('links.editor.updateButtonLabel', { defaultMessage: 'Update link', }), getEditLinkTitle: () => - i18n.translate('navigationEmbeddable.editor.editLinkTitle', { + i18n.translate('links.editor.editLinkTitle', { defaultMessage: 'Edit link', }), getDeleteLinkTitle: () => - i18n.translate('navigationEmbeddable.editor.deleteLinkTitle', { + i18n.translate('links.editor.deleteLinkTitle', { defaultMessage: 'Delete link', }), getCancelButtonLabel: () => - i18n.translate('navigationEmbeddable.editor.cancelButtonLabel', { + i18n.translate('links.editor.cancelButtonLabel', { defaultMessage: 'Close', }), panelEditor: { getLinksTitle: () => - i18n.translate('navigationEmbeddable.panelEditor.linksTitle', { + i18n.translate('links.panelEditor.linksTitle', { defaultMessage: 'Links', }), getEmptyLinksMessage: () => - i18n.translate('navigationEmbeddable.panelEditor.emptyLinksMessage', { + i18n.translate('links.panelEditor.emptyLinksMessage', { defaultMessage: "You haven't added any links yet.", }), getEmptyLinksTooltip: () => - i18n.translate('navigationEmbeddable.panelEditor.emptyLinksTooltip', { + i18n.translate('links.panelEditor.emptyLinksTooltip', { defaultMessage: 'Add one or more links.', }), getCreateFlyoutTitle: () => - i18n.translate('navigationEmbeddable.panelEditor.createFlyoutTitle', { + i18n.translate('links.panelEditor.createFlyoutTitle', { defaultMessage: 'Create links panel', }), getEditFlyoutTitle: () => - i18n.translate('navigationEmbeddable.panelEditor.editFlyoutTitle', { + i18n.translate('links.panelEditor.editFlyoutTitle', { defaultMessage: 'Edit links panel', }), getSaveButtonLabel: () => - i18n.translate('navigationEmbeddable.panelEditor.saveButtonLabel', { + i18n.translate('links.panelEditor.saveButtonLabel', { defaultMessage: 'Save', }), getSaveToLibrarySwitchLabel: () => - i18n.translate('navigationEmbeddable.panelEditor.saveToLibrarySwitchLabel', { + i18n.translate('links.panelEditor.saveToLibrarySwitchLabel', { defaultMessage: 'Save to library', }), getSaveToLibrarySwitchTooltip: () => - i18n.translate('navigationEmbeddable.panelEditor.saveToLibrarySwitchTooltip', { + i18n.translate('links.panelEditor.saveToLibrarySwitchTooltip', { defaultMessage: 'Save this links panel to the library so you can easily add it to other dashboards.', }), getTitleInputLabel: () => - i18n.translate('navigationEmbeddable.panelEditor.titleInputLabel', { + i18n.translate('links.panelEditor.titleInputLabel', { defaultMessage: 'Title', }), getBrokenDashboardLinkAriaLabel: () => - i18n.translate('navigationEmbeddable.panelEditor.brokenDashboardLinkAriaLabel', { + i18n.translate('links.panelEditor.brokenDashboardLinkAriaLabel', { defaultMessage: 'Broken dashboard link', }), getDragHandleAriaLabel: () => - i18n.translate('navigationEmbeddable.panelEditor.dragHandleAriaLabel', { + i18n.translate('links.panelEditor.dragHandleAriaLabel', { defaultMessage: 'Link drag handle', }), getLayoutSettingsTitle: () => - i18n.translate('navigationEmbeddable.panelEditor.layoutSettingsTitle', { + i18n.translate('links.panelEditor.layoutSettingsTitle', { defaultMessage: 'Layout', }), getLayoutSettingsLegend: () => - i18n.translate('navigationEmbeddable.panelEditor.layoutSettingsLegend', { + i18n.translate('links.panelEditor.layoutSettingsLegend', { defaultMessage: 'Choose how to display your links.', }), getHorizontalLayoutLabel: () => - i18n.translate('navigationEmbeddable.editor.horizontalLayout', { + i18n.translate('links.editor.horizontalLayout', { defaultMessage: 'Horizontal', }), getVerticalLayoutLabel: () => - i18n.translate('navigationEmbeddable.editor.verticalLayout', { + i18n.translate('links.editor.verticalLayout', { defaultMessage: 'Vertical', }), getErrorDuringSaveToastTitle: () => - i18n.translate('navigationEmbeddable.editor.unableToSaveToastTitle', { + i18n.translate('links.editor.unableToSaveToastTitle', { defaultMessage: 'Error saving Link panel', }), }, linkEditor: { getGoBackAriaLabel: () => - i18n.translate('navigationEmbeddable.linkEditor.goBackAriaLabel', { + i18n.translate('links.linkEditor.goBackAriaLabel', { defaultMessage: 'Go back to panel editor.', }), getLinkTypePickerLabel: () => - i18n.translate('navigationEmbeddable.linkEditor.linkTypeFormLabel', { + i18n.translate('links.linkEditor.linkTypeFormLabel', { defaultMessage: 'Go to', }), getLinkDestinationLabel: () => - i18n.translate('navigationEmbeddable.linkEditor.linkDestinationLabel', { + i18n.translate('links.linkEditor.linkDestinationLabel', { defaultMessage: 'Choose destination', }), getLinkTextLabel: () => - i18n.translate('navigationEmbeddable.linkEditor.linkTextLabel', { + i18n.translate('links.linkEditor.linkTextLabel', { defaultMessage: 'Text', }), getLinkTextPlaceholder: () => - i18n.translate('navigationEmbeddable.linkEditor.linkTextPlaceholder', { + i18n.translate('links.linkEditor.linkTextPlaceholder', { defaultMessage: 'Enter text for link', }), getLinkOptionsLabel: () => - i18n.translate('navigationEmbeddable.linkEditor.linkOptionsLabel', { + i18n.translate('links.linkEditor.linkOptionsLabel', { defaultMessage: 'Options', }), }, diff --git a/src/plugins/navigation_embeddable/public/components/tooltip_wrapper.tsx b/src/plugins/links/public/components/tooltip_wrapper.tsx similarity index 100% rename from src/plugins/navigation_embeddable/public/components/tooltip_wrapper.tsx rename to src/plugins/links/public/components/tooltip_wrapper.tsx diff --git a/src/plugins/navigation_embeddable/public/content_management/duplicate_title_check.ts b/src/plugins/links/public/content_management/duplicate_title_check.ts similarity index 82% rename from src/plugins/navigation_embeddable/public/content_management/duplicate_title_check.ts rename to src/plugins/links/public/content_management/duplicate_title_check.ts index 3115e110467e8..53d93502d7728 100644 --- a/src/plugins/navigation_embeddable/public/content_management/duplicate_title_check.ts +++ b/src/plugins/links/public/content_management/duplicate_title_check.ts @@ -7,9 +7,9 @@ */ import { i18n } from '@kbn/i18n'; -import { navigationEmbeddableClient } from './navigation_embeddable_content_management_client.ts'; +import { linksClient } from './links_content_management_client'; -const rejectErrorMessage = i18n.translate('navigationEmbeddable.saveDuplicateRejectedDescription', { +const rejectErrorMessage = i18n.translate('links.saveDuplicateRejectedDescription', { defaultMessage: 'Save with duplicate title confirmation was rejected', }); @@ -38,7 +38,7 @@ export const checkForDuplicateTitle = async ({ return true; } - const { hits } = await navigationEmbeddableClient.search( + const { hits } = await linksClient.search( { text: `"${title}"`, limit: 10, diff --git a/src/plugins/navigation_embeddable/public/content_management/index.ts b/src/plugins/links/public/content_management/index.ts similarity index 80% rename from src/plugins/navigation_embeddable/public/content_management/index.ts rename to src/plugins/links/public/content_management/index.ts index 883a28a34ad24..c7bc84b8f6b80 100644 --- a/src/plugins/navigation_embeddable/public/content_management/index.ts +++ b/src/plugins/links/public/content_management/index.ts @@ -6,5 +6,5 @@ * Side Public License, v 1. */ -export { navigationEmbeddableClient } from './navigation_embeddable_content_management_client.ts'; +export { linksClient } from './links_content_management_client'; export { checkForDuplicateTitle } from './duplicate_title_check'; diff --git a/src/plugins/links/public/content_management/links_content_management_client.ts b/src/plugins/links/public/content_management/links_content_management_client.ts new file mode 100644 index 0000000000000..777fd8731d691 --- /dev/null +++ b/src/plugins/links/public/content_management/links_content_management_client.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SearchQuery } from '@kbn/content-management-plugin/common'; +import type { LinksCrudTypes } from '../../common/content_management'; +import { CONTENT_ID as contentTypeId } from '../../common'; +import { contentManagement } from '../services/kibana_services'; + +const get = async (id: string) => { + return contentManagement.client.get({ + contentTypeId, + id, + }); +}; + +const create = async ({ data, options }: Omit) => { + const res = await contentManagement.client.create< + LinksCrudTypes['CreateIn'], + LinksCrudTypes['CreateOut'] + >({ + contentTypeId, + data, + options, + }); + return res; +}; + +const update = async ({ id, data, options }: Omit) => { + const res = await contentManagement.client.update< + LinksCrudTypes['UpdateIn'], + LinksCrudTypes['UpdateOut'] + >({ + contentTypeId, + id, + data, + options, + }); + return res; +}; + +const deleteLinks = async (id: string) => { + await contentManagement.client.delete({ + contentTypeId, + id, + }); +}; + +const search = async (query: SearchQuery = {}, options?: LinksCrudTypes['SearchOptions']) => { + return contentManagement.client.search({ + contentTypeId, + query, + options, + }); +}; + +export const linksClient = { + get, + create, + update, + delete: deleteLinks, + search, +}; diff --git a/src/plugins/navigation_embeddable/public/content_management/save_to_library.tsx b/src/plugins/links/public/content_management/save_to_library.tsx similarity index 72% rename from src/plugins/navigation_embeddable/public/content_management/save_to_library.tsx rename to src/plugins/links/public/content_management/save_to_library.tsx index 31274817c5cb0..b8014780894f0 100644 --- a/src/plugins/navigation_embeddable/public/content_management/save_to_library.tsx +++ b/src/plugins/links/public/content_management/save_to_library.tsx @@ -15,19 +15,16 @@ import { } from '@kbn/saved-objects-plugin/public'; import { APP_NAME } from '../../common'; -import { NavigationEmbeddableAttributes } from '../../common/content_management'; -import { - NavigationEmbeddableByReferenceInput, - NavigationEmbeddableInput, -} from '../embeddable/types'; +import { LinksAttributes } from '../../common/content_management'; +import { LinksByReferenceInput, LinksInput } from '../embeddable/types'; import { checkForDuplicateTitle } from './duplicate_title_check'; -import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; +import { getLinksAttributeService } from '../services/attribute_service'; export const runSaveToLibrary = async ( - newAttributes: NavigationEmbeddableAttributes, - initialInput: NavigationEmbeddableInput -): Promise => { - return new Promise((resolve) => { + newAttributes: LinksAttributes, + initialInput: LinksInput +): Promise => { + return new Promise((resolve) => { const onSave = async ({ newTitle, newDescription, @@ -56,11 +53,11 @@ export const runSaveToLibrary = async ( ...stateFromSaveModal, }; - const updatedInput = (await getNavigationEmbeddableAttributeService().wrapAttributes( + const updatedInput = (await getLinksAttributeService().wrapAttributes( stateToSave, true, initialInput - )) as unknown as NavigationEmbeddableByReferenceInput; + )) as unknown as LinksByReferenceInput; resolve(updatedInput); return { id: updatedInput.savedObjectId }; diff --git a/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx b/src/plugins/links/public/editor/links_editor_tools.tsx similarity index 59% rename from src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx rename to src/plugins/links/public/editor/links_editor_tools.tsx index 4248af756f525..780ef5fd21679 100644 --- a/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx +++ b/src/plugins/links/public/editor/links_editor_tools.tsx @@ -7,24 +7,24 @@ */ import { memoize } from 'lodash'; -import { NavigationEmbeddableLink } from '../../common/content_management'; +import { Link } from '../../common/content_management'; -const getOrderedLinkList = (links: NavigationEmbeddableLink[]): NavigationEmbeddableLink[] => { +const getOrderedLinkList = (links: Link[]): Link[] => { return [...links].sort((linkA, linkB) => { return linkA.order - linkB.order; }); }; /** - * Memoizing this prevents the navigation embeddable panel editor from having to unnecessarily calculate this - * a second time once the embeddable exists - after all, the navigation embeddable component should have already + * Memoizing this prevents the links panel editor from having to unnecessarily calculate this + * a second time once the embeddable exists - after all, the links component should have already * calculated this so, we can get away with using the cached version in the editor */ export const memoizedGetOrderedLinkList = memoize( - (links: NavigationEmbeddableLink[]) => { + (links: Link[]) => { return getOrderedLinkList(links); }, - (links: NavigationEmbeddableLink[]) => { + (links: Link[]) => { return links; } ); diff --git a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx b/src/plugins/links/public/editor/open_editor_flyout.tsx similarity index 73% rename from src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx rename to src/plugins/links/public/editor/open_editor_flyout.tsx index e51606927a912..6c8f74ea5934a 100644 --- a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx +++ b/src/plugins/links/public/editor/open_editor_flyout.tsx @@ -15,22 +15,16 @@ import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { - NavigationEmbeddableInput, - NavigationEmbeddableByReferenceInput, - NavigationEmbeddableEditorFlyoutReturn, -} from '../embeddable/types'; +import { LinksInput, LinksByReferenceInput, LinksEditorFlyoutReturn } from '../embeddable/types'; import { coreServices } from '../services/kibana_services'; import { runSaveToLibrary } from '../content_management/save_to_library'; -import { NavigationEmbeddableLink, NavigationLayoutType } from '../../common/content_management'; -import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; +import { Link, LinksLayoutType } from '../../common/content_management'; +import { getLinksAttributeService } from '../services/attribute_service'; -const LazyNavigationEmbeddablePanelEditor = React.lazy( - () => import('../components/editor/navigation_embeddable_panel_editor') -); +const LazyLinksEditor = React.lazy(() => import('../components/editor/links_editor')); -const NavigationEmbeddablePanelEditor = withSuspense( - LazyNavigationEmbeddablePanelEditor, +const LinksEditor = withSuspense( + LazyLinksEditor, @@ -40,26 +34,23 @@ const NavigationEmbeddablePanelEditor = withSuspense( * @throws in case user cancels */ export async function openEditorFlyout( - initialInput: NavigationEmbeddableInput, + initialInput: LinksInput, parentDashboard?: DashboardContainer -): Promise { - const attributeService = getNavigationEmbeddableAttributeService(); +): Promise { + const attributeService = getLinksAttributeService(); const { attributes } = await attributeService.unwrapAttributes(initialInput); const isByReference = attributeService.inputIsRefType(initialInput); return new Promise((resolve, reject) => { const closed$ = new Subject(); - const onSaveToLibrary = async ( - newLinks: NavigationEmbeddableLink[], - newLayout: NavigationLayoutType - ) => { + const onSaveToLibrary = async (newLinks: Link[], newLayout: LinksLayoutType) => { const newAttributes = { ...attributes, links: newLinks, layout: newLayout, }; - const updatedInput = (initialInput as NavigationEmbeddableByReferenceInput).savedObjectId + const updatedInput = (initialInput as LinksByReferenceInput).savedObjectId ? await attributeService.wrapAttributes(newAttributes, true, initialInput) : await runSaveToLibrary(newAttributes, initialInput); if (!updatedInput) { @@ -75,16 +66,13 @@ export async function openEditorFlyout( editorFlyout.close(); }; - const onAddToDashboard = ( - newLinks: NavigationEmbeddableLink[], - newLayout: NavigationLayoutType - ) => { + const onAddToDashboard = (newLinks: Link[], newLayout: LinksLayoutType) => { const newAttributes = { ...attributes, links: newLinks, layout: newLayout, }; - const newInput: NavigationEmbeddableInput = { + const newInput: LinksInput = { ...initialInput, attributes: newAttributes, }; @@ -114,7 +102,7 @@ export async function openEditorFlyout( const editorFlyout = coreServices.overlays.openFlyout( toMountPoint( - ; } @@ -26,7 +26,7 @@ export interface LinkEditorProps { * This editor has no context about other links, so it cannot determine order; order will be determined * by the **caller** (i.e. the panel editor, which contains the context about **all links**) */ -export type NavigationEmbeddableUnorderedLink = Omit; +export type UnorderedLink = Omit; /** * @throws in case user cancels @@ -35,10 +35,10 @@ export async function openLinkEditorFlyout({ ref, link, parentDashboard, -}: LinkEditorProps): Promise { +}: LinksEditorProps): Promise { const unmountFlyout = async () => { if (ref.current) { - ref.current.children[1].className = 'navEmbeddableLinkEditor out'; + ref.current.children[1].className = 'linkEditor out'; } await new Promise(() => { // wait for close animation before unmounting @@ -48,8 +48,8 @@ export async function openLinkEditorFlyout({ }); }; - return new Promise((resolve, reject) => { - const onSave = async (newLink: NavigationEmbeddableUnorderedLink) => { + return new Promise((resolve, reject) => { + const onSave = async (newLink: UnorderedLink) => { resolve(newLink); await unmountFlyout(); }; @@ -61,7 +61,7 @@ export async function openLinkEditorFlyout({ ReactDOM.render( - (null); -export const useNavigationEmbeddable = (): NavigationEmbeddable => { - const navigation = useContext(NavigationEmbeddableContext); - if (navigation == null) { - throw new Error('useNavigation must be used inside NavigationEmbeddableContext.'); +export const LinksContext = createContext(null); +export const useLinks = (): LinksEmbeddable => { + const linksEmbeddable = useContext(LinksContext); + if (linksEmbeddable == null) { + throw new Error('useLinks must be used inside LinksContext.'); } - return navigation!; + return linksEmbeddable!; }; -type NavigationReduxEmbeddableTools = ReduxEmbeddableTools< - NavigationEmbeddableReduxState, - typeof navigationEmbeddableReducers ->; +type LinksReduxEmbeddableTools = ReduxEmbeddableTools; -export interface NavigationEmbeddableConfig { +export interface LinksConfig { editable: boolean; } -export class NavigationEmbeddable - extends Embeddable - implements - ReferenceOrValueEmbeddable< - NavigationEmbeddableByValueInput, - NavigationEmbeddableByReferenceInput - > +export class LinksEmbeddable + extends Embeddable + implements ReferenceOrValueEmbeddable { public readonly type = CONTENT_ID; deferEmbeddableLoad = true; @@ -63,18 +52,18 @@ export class NavigationEmbeddable private subscriptions: Subscription = new Subscription(); // state management - public select: NavigationReduxEmbeddableTools['select']; - public getState: NavigationReduxEmbeddableTools['getState']; - public dispatch: NavigationReduxEmbeddableTools['dispatch']; - public onStateChange: NavigationReduxEmbeddableTools['onStateChange']; + public select: LinksReduxEmbeddableTools['select']; + public getState: LinksReduxEmbeddableTools['getState']; + public dispatch: LinksReduxEmbeddableTools['dispatch']; + public onStateChange: LinksReduxEmbeddableTools['onStateChange']; private cleanupStateTools: () => void; constructor( reduxToolsPackage: ReduxToolsPackage, - config: NavigationEmbeddableConfig, - initialInput: NavigationEmbeddableInput, - private attributeService: AttributeService, + config: LinksConfig, + initialInput: LinksInput, + private attributeService: AttributeService, parent?: DashboardContainer ) { super( @@ -88,11 +77,11 @@ export class NavigationEmbeddable /** Build redux embeddable tools */ const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools< - NavigationEmbeddableReduxState, - typeof navigationEmbeddableReducers + LinksReduxState, + typeof linksReducers >({ embeddable: this, - reducers: navigationEmbeddableReducers, + reducers: linksReducers, initialComponentState: { title: '', }, @@ -139,8 +128,8 @@ export class NavigationEmbeddable } public inputIsRefType( - input: NavigationEmbeddableByValueInput | NavigationEmbeddableByReferenceInput - ): input is NavigationEmbeddableByReferenceInput { + input: LinksByValueInput | LinksByReferenceInput + ): input is LinksByReferenceInput { return this.attributeService.inputIsRefType(input); } @@ -151,7 +140,7 @@ export class NavigationEmbeddable }); } - public async getInputAsValueType(): Promise { + public async getInputAsValueType(): Promise { return this.attributeService.getInputAsValueType(this.getExplicitInput()); } @@ -172,9 +161,9 @@ export class NavigationEmbeddable public render() { if (this.isDestroyed) return; return ( - - - + + + ); } } diff --git a/src/plugins/links/public/embeddable/links_embeddable_factory.test.ts b/src/plugins/links/public/embeddable/links_embeddable_factory.test.ts new file mode 100644 index 0000000000000..427827a1ace4b --- /dev/null +++ b/src/plugins/links/public/embeddable/links_embeddable_factory.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LinksFactoryDefinition } from './links_embeddable_factory'; +import { LinksInput } from './types'; + +describe('linksFactory', () => { + test('returns an empty object when not given proper meta information', () => { + const linksFactory = new LinksFactoryDefinition(); + const settings = linksFactory.getPanelPlacementSettings({} as unknown as LinksInput, {}); + expect(settings.height).toBeUndefined(); + expect(settings.width).toBeUndefined(); + expect(settings.strategy).toBeUndefined(); + }); + + test('returns a horizontal layout', () => { + const linksFactory = new LinksFactoryDefinition(); + const settings = linksFactory.getPanelPlacementSettings({} as unknown as LinksInput, { + layout: 'horizontal', + links: [], + }); + expect(settings.height).toBe(4); + expect(settings.width).toBe(48); + expect(settings.strategy).toBe('placeAtTop'); + }); + + test('returns a vertical layout with the appropriate height', () => { + const linksFactory = new LinksFactoryDefinition(); + const settings = linksFactory.getPanelPlacementSettings({} as unknown as LinksInput, { + layout: 'vertical', + links: [ + { type: 'dashboardLink', destination: 'superDashboard1' }, + { type: 'dashboardLink', destination: 'superDashboard2' }, + { type: 'dashboardLink', destination: 'superDashboard3' }, + ], + }); + expect(settings.height).toBe(7); // 4 base plus 3 for each link. + expect(settings.width).toBe(8); + expect(settings.strategy).toBe('placeAtTop'); + }); +}); diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts b/src/plugins/links/public/embeddable/links_embeddable_factory.ts similarity index 62% rename from src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts rename to src/plugins/links/public/embeddable/links_embeddable_factory.ts index dcd8e51382b06..c4b678238baa5 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts +++ b/src/plugins/links/public/embeddable/links_embeddable_factory.ts @@ -22,43 +22,32 @@ import { } from '@kbn/kibana-utils-plugin/common'; import { UiActionsPresentableGrouping } from '@kbn/ui-actions-plugin/public'; import { DASHBOARD_GRID_COLUMN_COUNT } from '@kbn/dashboard-plugin/public'; -import { - NavigationEmbeddableInput, - NavigationEmbeddableByReferenceInput, - NavigationEmbeddableEditorFlyoutReturn, -} from './types'; +import { LinksInput, LinksByReferenceInput, LinksEditorFlyoutReturn } from './types'; import { extract, inject } from '../../common/embeddable'; import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; -import type { NavigationEmbeddable } from './navigation_embeddable'; -import { NavigationEmbeddableAttributes } from '../../common/content_management'; -import { NavEmbeddableStrings } from '../components/navigation_embeddable_strings'; -import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; +import type { LinksEmbeddable } from './links_embeddable'; +import { LinksAttributes } from '../../common/content_management'; +import { LinksStrings } from '../components/links_strings'; +import { getLinksAttributeService } from '../services/attribute_service'; import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; -export type NavigationEmbeddableFactory = EmbeddableFactory; +export type LinksFactory = EmbeddableFactory; // TODO: Replace string 'OPEN_FLYOUT_ADD_DRILLDOWN' with constant once the dashboardEnhanced plugin is removed // and it is no longer locked behind `x-pack` -const getDefaultNavigationEmbeddableInput = (): Partial => ({ +const getDefaultLinksInput = (): Partial => ({ disabledActions: ['OPEN_FLYOUT_ADD_DRILLDOWN'], }); -const isNavigationEmbeddableAttributes = ( - attributes?: unknown -): attributes is NavigationEmbeddableAttributes => { +const isLinksAttributes = (attributes?: unknown): attributes is LinksAttributes => { return ( attributes !== undefined && - Boolean( - (attributes as NavigationEmbeddableAttributes).layout || - (attributes as NavigationEmbeddableAttributes).links - ) + Boolean((attributes as LinksAttributes).layout || (attributes as LinksAttributes).links) ); }; -export class NavigationEmbeddableFactoryDefinition - implements - EmbeddableFactoryDefinition, - IProvidesPanelPlacementSettings +export class LinksFactoryDefinition + implements EmbeddableFactoryDefinition, IProvidesPanelPlacementSettings { latestVersion?: string | undefined; telemetry?: @@ -77,11 +66,11 @@ export class NavigationEmbeddableFactoryDefinition }; public getPanelPlacementSettings: IProvidesPanelPlacementSettings< - NavigationEmbeddableInput, - NavigationEmbeddableAttributes | unknown + LinksInput, + LinksAttributes | unknown >['getPanelPlacementSettings'] = (input, attributes) => { - if (!isNavigationEmbeddableAttributes(attributes) || !attributes.layout) { - // if we have no information about the layout of this nav embeddable defer to default panel size and placement. + if (!isLinksAttributes(attributes) || !attributes.layout) { + // if we have no information about the layout of this links panel defer to default panel size and placement. return {}; } @@ -100,48 +89,48 @@ export class NavigationEmbeddableFactoryDefinition return true; } - public getDefaultInput(): Partial { - return getDefaultNavigationEmbeddableInput(); + public getDefaultInput(): Partial { + return getDefaultLinksInput(); } public async createFromSavedObject( savedObjectId: string, - input: NavigationEmbeddableInput, + input: LinksInput, parent: DashboardContainer - ): Promise { - if (!(input as NavigationEmbeddableByReferenceInput).savedObjectId) { - (input as NavigationEmbeddableByReferenceInput).savedObjectId = savedObjectId; + ): Promise { + if (!(input as LinksByReferenceInput).savedObjectId) { + (input as LinksByReferenceInput).savedObjectId = savedObjectId; } return this.create(input, parent); } - public async create(initialInput: NavigationEmbeddableInput, parent: DashboardContainer) { + public async create(initialInput: LinksInput, parent: DashboardContainer) { await untilPluginStartServicesReady(); const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage(); - const { NavigationEmbeddable } = await import('./navigation_embeddable'); + const { LinksEmbeddable } = await import('./links_embeddable'); const editable = await this.isEditable(); - return new NavigationEmbeddable( + return new LinksEmbeddable( reduxEmbeddablePackage, { editable }, - { ...getDefaultNavigationEmbeddableInput(), ...initialInput }, - getNavigationEmbeddableAttributeService(), + { ...getDefaultLinksInput(), ...initialInput }, + getLinksAttributeService(), parent ); } public async getExplicitInput( - initialInput: NavigationEmbeddableInput, + initialInput: LinksInput, parent?: DashboardContainer - ): Promise { + ): Promise { if (!parent) return { newInput: {} }; const { openEditorFlyout } = await import('../editor/open_editor_flyout'); const { newInput, attributes } = await openEditorFlyout( { - ...getDefaultNavigationEmbeddableInput(), + ...getDefaultLinksInput(), ...initialInput, }, parent @@ -159,7 +148,7 @@ export class NavigationEmbeddableFactoryDefinition } public getDescription() { - return NavEmbeddableStrings.getDescription(); + return LinksStrings.getDescription(); } inject = inject; diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts b/src/plugins/links/public/embeddable/links_reducers.ts similarity index 66% rename from src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts rename to src/plugins/links/public/embeddable/links_reducers.ts index b671c395e061b..ff96b51fddc5f 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts +++ b/src/plugins/links/public/embeddable/links_reducers.ts @@ -10,25 +10,22 @@ import { WritableDraft } from 'immer/dist/types/types-external'; import { PayloadAction } from '@reduxjs/toolkit'; -import { NavigationEmbeddableReduxState } from './types'; -import { NavigationEmbeddableAttributes } from '../../common/content_management'; +import { LinksReduxState } from './types'; +import { LinksAttributes } from '../../common/content_management'; -export const navigationEmbeddableReducers = { +export const linksReducers = { /** * TODO: Right now, we aren't using any reducers - but, I'm keeping this here as a draft * just in case we need them later on. As a final cleanup, we could remove this if we never * end up using reducers */ - setLoading: ( - state: WritableDraft, - action: PayloadAction - ) => { + setLoading: (state: WritableDraft, action: PayloadAction) => { state.output.loading = action.payload; }, setAttributes: ( - state: WritableDraft, - action: PayloadAction + state: WritableDraft, + action: PayloadAction ) => { state.componentState = { ...action.payload }; }, diff --git a/src/plugins/navigation_embeddable/public/embeddable/types.ts b/src/plugins/links/public/embeddable/types.ts similarity index 53% rename from src/plugins/navigation_embeddable/public/embeddable/types.ts rename to src/plugins/links/public/embeddable/types.ts index 2804712da504d..d16d8431a5601 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/types.ts +++ b/src/plugins/links/public/embeddable/types.ts @@ -15,26 +15,26 @@ import { import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; import { - NavigationLinkType, + LinkType, EXTERNAL_LINK_TYPE, DASHBOARD_LINK_TYPE, - NAV_VERTICAL_LAYOUT, - NavigationLayoutType, - NAV_HORIZONTAL_LAYOUT, - NavigationEmbeddableAttributes, + LINKS_VERTICAL_LAYOUT, + LinksLayoutType, + LINKS_HORIZONTAL_LAYOUT, + LinksAttributes, } from '../../common/content_management'; import { DashboardLinkStrings } from '../components/dashboard_link/dashboard_link_strings'; import { ExternalLinkStrings } from '../components/external_link/external_link_strings'; -import { NavEmbeddableStrings } from '../components/navigation_embeddable_strings'; +import { LinksStrings } from '../components/links_strings'; -export const NavigationLayoutInfo: { - [id in NavigationLayoutType]: { displayName: string }; +export const LinksLayoutInfo: { + [id in LinksLayoutType]: { displayName: string }; } = { - [NAV_HORIZONTAL_LAYOUT]: { - displayName: NavEmbeddableStrings.editor.panelEditor.getHorizontalLayoutLabel(), + [LINKS_HORIZONTAL_LAYOUT]: { + displayName: LinksStrings.editor.panelEditor.getHorizontalLayoutLabel(), }, - [NAV_VERTICAL_LAYOUT]: { - displayName: NavEmbeddableStrings.editor.panelEditor.getVerticalLayoutLabel(), + [LINKS_VERTICAL_LAYOUT]: { + displayName: LinksStrings.editor.panelEditor.getVerticalLayoutLabel(), }, }; @@ -43,8 +43,8 @@ export interface DashboardItem { attributes: DashboardAttributes; } -export const NavigationLinkInfo: { - [id in NavigationLinkType]: { +export const LinkInfo: { + [id in LinkType]: { icon: string; type: string; displayName: string; @@ -65,32 +65,26 @@ export const NavigationLinkInfo: { }, }; -export interface NavigationEmbeddableEditorFlyoutReturn { +export interface LinksEditorFlyoutReturn { attributes?: unknown; - newInput: Partial; + newInput: Partial; } -export type NavigationEmbeddableByValueInput = { - attributes: NavigationEmbeddableAttributes; +export type LinksByValueInput = { + attributes: LinksAttributes; } & EmbeddableInput; -export type NavigationEmbeddableByReferenceInput = SavedObjectEmbeddableInput; +export type LinksByReferenceInput = SavedObjectEmbeddableInput; -export type NavigationEmbeddableInput = - | NavigationEmbeddableByValueInput - | NavigationEmbeddableByReferenceInput; +export type LinksInput = LinksByValueInput | LinksByReferenceInput; -export type NavigationEmbeddableOutput = EmbeddableOutput & { - attributes?: NavigationEmbeddableAttributes; +export type LinksOutput = EmbeddableOutput & { + attributes?: LinksAttributes; }; /** - * Navigation embeddable redux state + * Links embeddable redux state */ -export type NavigationEmbeddableComponentState = NavigationEmbeddableAttributes; +export type LinksComponentState = LinksAttributes; -export type NavigationEmbeddableReduxState = ReduxEmbeddableState< - NavigationEmbeddableInput, - NavigationEmbeddableOutput, - NavigationEmbeddableComponentState ->; +export type LinksReduxState = ReduxEmbeddableState; diff --git a/src/plugins/links/public/index.ts b/src/plugins/links/public/index.ts new file mode 100644 index 0000000000000..3389cd48f4b67 --- /dev/null +++ b/src/plugins/links/public/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { LinksFactory } from './embeddable'; +export { LinksFactoryDefinition, LinksEmbeddable } from './embeddable'; + +import { LinksPlugin } from './plugin'; + +export function plugin() { + return new LinksPlugin(); +} diff --git a/src/plugins/navigation_embeddable/public/mocks.tsx b/src/plugins/links/public/mocks.tsx similarity index 100% rename from src/plugins/navigation_embeddable/public/mocks.tsx rename to src/plugins/links/public/mocks.tsx diff --git a/src/plugins/navigation_embeddable/public/plugin.ts b/src/plugins/links/public/plugin.ts similarity index 65% rename from src/plugins/navigation_embeddable/public/plugin.ts rename to src/plugins/links/public/plugin.ts index 8863c4393b058..d799213e2a18a 100644 --- a/src/plugins/navigation_embeddable/public/plugin.ts +++ b/src/plugins/links/public/plugin.ts @@ -13,42 +13,30 @@ import { } from '@kbn/content-management-plugin/public'; import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import { NavigationEmbeddableFactoryDefinition } from './embeddable'; +import { LinksFactoryDefinition } from './embeddable'; import { CONTENT_ID, LATEST_VERSION } from '../common'; import { APP_NAME } from '../common'; import { setKibanaServices } from './services/kibana_services'; -export interface NavigationEmbeddableSetupDependencies { +export interface LinksSetupDependencies { embeddable: EmbeddableSetup; contentManagement: ContentManagementPublicSetup; } -export interface NavigationEmbeddableStartDependencies { +export interface LinksStartDependencies { embeddable: EmbeddableStart; contentManagement: ContentManagementPublicStart; dashboard: DashboardStart; } -export class NavigationEmbeddablePlugin - implements - Plugin< - void, - void, - NavigationEmbeddableSetupDependencies, - NavigationEmbeddableStartDependencies - > +export class LinksPlugin + implements Plugin { constructor() {} - public setup( - core: CoreSetup, - plugins: NavigationEmbeddableSetupDependencies - ) { + public setup(core: CoreSetup, plugins: LinksSetupDependencies) { core.getStartServices().then(([_, deps]) => { - plugins.embeddable.registerEmbeddableFactory( - CONTENT_ID, - new NavigationEmbeddableFactoryDefinition(deps.embeddable) - ); + plugins.embeddable.registerEmbeddableFactory(CONTENT_ID, new LinksFactoryDefinition()); plugins.contentManagement.registry.register({ id: CONTENT_ID, @@ -60,7 +48,7 @@ export class NavigationEmbeddablePlugin }); } - public start(core: CoreStart, plugins: NavigationEmbeddableStartDependencies) { + public start(core: CoreStart, plugins: LinksStartDependencies) { setKibanaServices(core, plugins); return {}; } diff --git a/src/plugins/navigation_embeddable/public/services/attribute_service.ts b/src/plugins/links/public/services/attribute_service.ts similarity index 57% rename from src/plugins/navigation_embeddable/public/services/attribute_service.ts rename to src/plugins/links/public/services/attribute_service.ts index 74530871fc469..bde2ab27c1d15 100644 --- a/src/plugins/navigation_embeddable/public/services/attribute_service.ts +++ b/src/plugins/links/public/services/attribute_service.ts @@ -10,42 +10,39 @@ import { Reference } from '@kbn/content-management-utils'; import { AttributeService } from '@kbn/embeddable-plugin/public'; import type { OnSaveProps } from '@kbn/saved-objects-plugin/public'; import { SharingSavedObjectProps } from '../../common/types'; -import { NavigationEmbeddableAttributes } from '../../common/content_management'; +import { LinksAttributes } from '../../common/content_management'; import { extractReferences, injectReferences } from '../../common/persistable_state'; -import { - NavigationEmbeddableByReferenceInput, - NavigationEmbeddableByValueInput, -} from '../embeddable/types'; +import { LinksByReferenceInput, LinksByValueInput } from '../embeddable/types'; import { embeddableService } from './kibana_services'; -import { checkForDuplicateTitle, navigationEmbeddableClient } from '../content_management'; +import { checkForDuplicateTitle, linksClient } from '../content_management'; import { CONTENT_ID } from '../../common'; -export type NavigationEmbeddableDocument = NavigationEmbeddableAttributes & { +export type LinksDocument = LinksAttributes & { references?: Reference[]; }; -export interface NavigationEmbeddableUnwrapMetaInfo { +export interface LinksUnwrapMetaInfo { sharingSavedObjectProps?: SharingSavedObjectProps; } -export type NavigationEmbeddableAttributeService = AttributeService< - NavigationEmbeddableDocument, - NavigationEmbeddableByValueInput, - NavigationEmbeddableByReferenceInput, - NavigationEmbeddableUnwrapMetaInfo +export type LinksAttributeService = AttributeService< + LinksDocument, + LinksByValueInput, + LinksByReferenceInput, + LinksUnwrapMetaInfo >; -let navigationEmbeddableAttributeService: NavigationEmbeddableAttributeService | null = null; -export function getNavigationEmbeddableAttributeService(): NavigationEmbeddableAttributeService { - if (navigationEmbeddableAttributeService) return navigationEmbeddableAttributeService; +let linksAttributeService: LinksAttributeService | null = null; +export function getLinksAttributeService(): LinksAttributeService { + if (linksAttributeService) return linksAttributeService; - navigationEmbeddableAttributeService = embeddableService.getAttributeService< - NavigationEmbeddableDocument, - NavigationEmbeddableByValueInput, - NavigationEmbeddableByReferenceInput, - NavigationEmbeddableUnwrapMetaInfo + linksAttributeService = embeddableService.getAttributeService< + LinksDocument, + LinksByValueInput, + LinksByReferenceInput, + LinksUnwrapMetaInfo >(CONTENT_ID, { - saveMethod: async (attributes: NavigationEmbeddableDocument, savedObjectId?: string) => { + saveMethod: async (attributes: LinksDocument, savedObjectId?: string) => { const { attributes: updatedAttributes, references } = extractReferences({ attributes, references: attributes.references, @@ -53,24 +50,24 @@ export function getNavigationEmbeddableAttributeService(): NavigationEmbeddableA const { item: { id }, } = await (savedObjectId - ? navigationEmbeddableClient.update({ + ? linksClient.update({ id: savedObjectId, data: updatedAttributes, options: { references }, }) - : navigationEmbeddableClient.create({ data: updatedAttributes, options: { references } })); + : linksClient.create({ data: updatedAttributes, options: { references } })); return { id }; }, unwrapMethod: async ( savedObjectId: string ): Promise<{ - attributes: NavigationEmbeddableDocument; - metaInfo: NavigationEmbeddableUnwrapMetaInfo; + attributes: LinksDocument; + metaInfo: LinksUnwrapMetaInfo; }> => { const { item: savedObject, meta: { outcome, aliasPurpose, aliasTargetId }, - } = await navigationEmbeddableClient.get(savedObjectId); + } = await linksClient.get(savedObjectId); if (savedObject.error) throw savedObject.error; const { attributes } = injectReferences(savedObject); @@ -96,5 +93,5 @@ export function getNavigationEmbeddableAttributeService(): NavigationEmbeddableA }); }, }); - return navigationEmbeddableAttributeService; + return linksAttributeService; } diff --git a/src/plugins/navigation_embeddable/public/services/kibana_services.ts b/src/plugins/links/public/services/kibana_services.ts similarity index 88% rename from src/plugins/navigation_embeddable/public/services/kibana_services.ts rename to src/plugins/links/public/services/kibana_services.ts index ddc5daad6495a..88b700df931db 100644 --- a/src/plugins/navigation_embeddable/public/services/kibana_services.ts +++ b/src/plugins/links/public/services/kibana_services.ts @@ -13,7 +13,7 @@ import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import { NavigationEmbeddableStartDependencies } from '../plugin'; +import { LinksStartDependencies } from '../plugin'; export let coreServices: CoreStart; export let dashboardServices: DashboardStart; @@ -34,10 +34,7 @@ export const untilPluginStartServicesReady = () => { }); }; -export const setKibanaServices = ( - kibanaCore: CoreStart, - deps: NavigationEmbeddableStartDependencies -) => { +export const setKibanaServices = (kibanaCore: CoreStart, deps: LinksStartDependencies) => { coreServices = kibanaCore; dashboardServices = deps.dashboard; embeddableService = deps.embeddable; diff --git a/src/plugins/navigation_embeddable/server/saved_objects/index.ts b/src/plugins/links/server/content_management/index.ts similarity index 81% rename from src/plugins/navigation_embeddable/server/saved_objects/index.ts rename to src/plugins/links/server/content_management/index.ts index 1c33d59959426..82666a940d249 100644 --- a/src/plugins/navigation_embeddable/server/saved_objects/index.ts +++ b/src/plugins/links/server/content_management/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { navigationEmbeddableSavedObjectType } from './navigation_embeddable'; +export { LinksStorage } from './links_storage'; diff --git a/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts b/src/plugins/links/server/content_management/links_storage.ts similarity index 80% rename from src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts rename to src/plugins/links/server/content_management/links_storage.ts index db830dfad512d..b5f5180330164 100644 --- a/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts +++ b/src/plugins/links/server/content_management/links_storage.ts @@ -8,10 +8,10 @@ import { SOContentStorage } from '@kbn/content-management-utils'; import { CONTENT_ID } from '../../common'; -import type { NavigationEmbeddableCrudTypes } from '../../common/content_management'; +import type { LinksCrudTypes } from '../../common/content_management'; import { cmServicesDefinition } from '../../common/content_management/cm_services'; -export class NavigationEmbeddableStorage extends SOContentStorage { +export class LinksStorage extends SOContentStorage { constructor() { super({ savedObjectType: CONTENT_ID, diff --git a/src/plugins/navigation_embeddable/server/index.ts b/src/plugins/links/server/index.ts similarity index 73% rename from src/plugins/navigation_embeddable/server/index.ts rename to src/plugins/links/server/index.ts index 6ececdd95b5d0..781ca3c1274ab 100644 --- a/src/plugins/navigation_embeddable/server/index.ts +++ b/src/plugins/links/server/index.ts @@ -6,6 +6,6 @@ * Side Public License, v 1. */ -import { NavigationEmbeddableServerPlugin } from './plugin'; +import { LinksServerPlugin } from './plugin'; -export const plugin = () => new NavigationEmbeddableServerPlugin(); +export const plugin = () => new LinksServerPlugin(); diff --git a/src/plugins/navigation_embeddable/server/plugin.ts b/src/plugins/links/server/plugin.ts similarity index 66% rename from src/plugins/navigation_embeddable/server/plugin.ts rename to src/plugins/links/server/plugin.ts index 05e3ca79f9971..de40f9f4b36cc 100644 --- a/src/plugins/navigation_embeddable/server/plugin.ts +++ b/src/plugins/links/server/plugin.ts @@ -9,11 +9,11 @@ import { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; import { CONTENT_ID, LATEST_VERSION } from '../common'; -import { NavigationEmbeddableAttributes } from '../common/content_management'; -import { NavigationEmbeddableStorage } from './content_management'; -import { navigationEmbeddableSavedObjectType } from './saved_objects'; +import { LinksAttributes } from '../common/content_management'; +import { LinksStorage } from './content_management'; +import { linksSavedObjectType } from './saved_objects'; -export class NavigationEmbeddableServerPlugin implements Plugin { +export class LinksServerPlugin implements Plugin { public setup( core: CoreSetup, plugins: { @@ -22,15 +22,13 @@ export class NavigationEmbeddableServerPlugin implements Plugin ) { plugins.contentManagement.register({ id: CONTENT_ID, - storage: new NavigationEmbeddableStorage(), + storage: new LinksStorage(), version: { latest: LATEST_VERSION, }, }); - core.savedObjects.registerType( - navigationEmbeddableSavedObjectType - ); + core.savedObjects.registerType(linksSavedObjectType); return {}; } diff --git a/src/plugins/navigation_embeddable/server/content_management/index.ts b/src/plugins/links/server/saved_objects/index.ts similarity index 81% rename from src/plugins/navigation_embeddable/server/content_management/index.ts rename to src/plugins/links/server/saved_objects/index.ts index 2376765bcac83..d6303bb2b8b78 100644 --- a/src/plugins/navigation_embeddable/server/content_management/index.ts +++ b/src/plugins/links/server/saved_objects/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { NavigationEmbeddableStorage } from './navigation_embeddable_storage'; +export { linksSavedObjectType } from './links'; diff --git a/src/plugins/navigation_embeddable/server/saved_objects/navigation_embeddable.ts b/src/plugins/links/server/saved_objects/links.ts similarity index 93% rename from src/plugins/navigation_embeddable/server/saved_objects/navigation_embeddable.ts rename to src/plugins/links/server/saved_objects/links.ts index f2855e3e44c90..1dd0c8e618a59 100644 --- a/src/plugins/navigation_embeddable/server/saved_objects/navigation_embeddable.ts +++ b/src/plugins/links/server/saved_objects/links.ts @@ -10,7 +10,7 @@ import type { SavedObjectsType } from '@kbn/core/server'; import { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; import { APP_ICON, CONTENT_ID } from '../../common'; -export const navigationEmbeddableSavedObjectType: SavedObjectsType = { +export const linksSavedObjectType: SavedObjectsType = { name: CONTENT_ID, indexPattern: ANALYTICS_SAVED_OBJECT_INDEX, hidden: false, diff --git a/src/plugins/navigation_embeddable/tsconfig.json b/src/plugins/links/tsconfig.json similarity index 100% rename from src/plugins/navigation_embeddable/tsconfig.json rename to src/plugins/links/tsconfig.json diff --git a/src/plugins/navigation_embeddable/README.md b/src/plugins/navigation_embeddable/README.md deleted file mode 100644 index 598a29be2e037..0000000000000 --- a/src/plugins/navigation_embeddable/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Navigation Embeddable - -This plugin adds the Navigation Embeddable which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard. diff --git a/src/plugins/navigation_embeddable/common/mocks.tsx b/src/plugins/navigation_embeddable/common/mocks.tsx deleted file mode 100644 index 18e529d195b1e..0000000000000 --- a/src/plugins/navigation_embeddable/common/mocks.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { buildMockDashboard } from '@kbn/dashboard-plugin/public/mocks'; -import { DashboardContainerInput } from '@kbn/dashboard-plugin/common'; -import { NavigationEmbeddableByValueInput } from '../public/embeddable/types'; -import { NavigationEmbeddableFactoryDefinition } from '../public'; -import { NavigationEmbeddableAttributes } from './content_management'; - -jest.mock('../public/services/attribute_service', () => { - return { - getNavigationEmbeddableAttributeService: jest.fn(() => { - return { - saveMethod: jest.fn(), - unwrapMethod: jest.fn(), - checkForDuplicateTitle: jest.fn(), - unwrapAttributes: jest.fn((attributes: NavigationEmbeddableByValueInput) => - Promise.resolve(attributes) - ), - wrapAttributes: jest.fn((attributes: NavigationEmbeddableAttributes) => - Promise.resolve(attributes) - ), - }; - }), - }; -}); - -export const mockNavigationEmbeddableInput = ( - partial?: Partial -): NavigationEmbeddableByValueInput => ({ - id: 'mocked_links_panel', - attributes: { - title: 'mocked_links', - }, - ...(partial ?? {}), -}); - -export const mockNavigationEmbeddable = async ({ - explicitInput, - dashboardExplicitInput, -}: { - explicitInput?: Partial; - dashboardExplicitInput?: Partial; -}) => { - const dashboardContainer = buildMockDashboard({ - overrides: dashboardExplicitInput, - savedObjectId: '123', - }); - const navigationEmbeddableFactoryStub = new NavigationEmbeddableFactoryDefinition(); - - const navigationEmbeddable = await navigationEmbeddableFactoryStub.create( - mockNavigationEmbeddableInput(explicitInput), - dashboardContainer - ); - - return navigationEmbeddable; -}; diff --git a/src/plugins/navigation_embeddable/public/assets/empty_links_dark.svg b/src/plugins/navigation_embeddable/public/assets/empty_links_dark.svg deleted file mode 100644 index 6540d66a39419..0000000000000 --- a/src/plugins/navigation_embeddable/public/assets/empty_links_dark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/assets/empty_links_light.svg b/src/plugins/navigation_embeddable/public/assets/empty_links_light.svg deleted file mode 100644 index 5bd8a7f8d5878..0000000000000 --- a/src/plugins/navigation_embeddable/public/assets/empty_links_light.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts b/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts deleted file mode 100644 index f7cb54da23937..0000000000000 --- a/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import type { SearchQuery } from '@kbn/content-management-plugin/common'; -import type { NavigationEmbeddableCrudTypes } from '../../common/content_management'; -import { CONTENT_ID as contentTypeId } from '../../common'; -import { contentManagement } from '../services/kibana_services'; - -const get = async (id: string) => { - return contentManagement.client.get< - NavigationEmbeddableCrudTypes['GetIn'], - NavigationEmbeddableCrudTypes['GetOut'] - >({ contentTypeId, id }); -}; - -const create = async ({ - data, - options, -}: Omit) => { - const res = await contentManagement.client.create< - NavigationEmbeddableCrudTypes['CreateIn'], - NavigationEmbeddableCrudTypes['CreateOut'] - >({ - contentTypeId, - data, - options, - }); - return res; -}; - -const update = async ({ - id, - data, - options, -}: Omit) => { - const res = await contentManagement.client.update< - NavigationEmbeddableCrudTypes['UpdateIn'], - NavigationEmbeddableCrudTypes['UpdateOut'] - >({ - contentTypeId, - id, - data, - options, - }); - return res; -}; - -const deleteNavigationEmbeddable = async (id: string) => { - await contentManagement.client.delete< - NavigationEmbeddableCrudTypes['DeleteIn'], - NavigationEmbeddableCrudTypes['DeleteOut'] - >({ - contentTypeId, - id, - }); -}; - -const search = async ( - query: SearchQuery = {}, - options?: NavigationEmbeddableCrudTypes['SearchOptions'] -) => { - return contentManagement.client.search< - NavigationEmbeddableCrudTypes['SearchIn'], - NavigationEmbeddableCrudTypes['SearchOut'] - >({ - contentTypeId, - query, - options, - }); -}; - -export const navigationEmbeddableClient = { - get, - create, - update, - delete: deleteNavigationEmbeddable, - search, -}; diff --git a/src/plugins/navigation_embeddable/public/embeddable/index.ts b/src/plugins/navigation_embeddable/public/embeddable/index.ts deleted file mode 100644 index eeaae05334801..0000000000000 --- a/src/plugins/navigation_embeddable/public/embeddable/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { NavigationEmbeddable } from './navigation_embeddable'; -export type { NavigationEmbeddableFactory } from './navigation_embeddable_factory'; -export { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable_factory'; diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.test.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.test.ts deleted file mode 100644 index c1dc4deee581f..0000000000000 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable_factory'; -import { NavigationEmbeddableInput } from './types'; - -describe('navigationEmbeddableFactory', () => { - test('returns an empty object when not given proper meta information', () => { - const navigationEmbeddableFactory = new NavigationEmbeddableFactoryDefinition(); - const settings = navigationEmbeddableFactory.getPanelPlacementSettings( - {} as unknown as NavigationEmbeddableInput, - {} - ); - expect(settings.height).toBeUndefined(); - expect(settings.width).toBeUndefined(); - expect(settings.strategy).toBeUndefined(); - }); - - test('returns a horizontal layout', () => { - const navigationEmbeddableFactory = new NavigationEmbeddableFactoryDefinition(); - const settings = navigationEmbeddableFactory.getPanelPlacementSettings( - {} as unknown as NavigationEmbeddableInput, - { layout: 'horizontal', links: [] } - ); - expect(settings.height).toBe(4); - expect(settings.width).toBe(48); - expect(settings.strategy).toBe('placeAtTop'); - }); - - test('returns a vertical layout with the appropriate height', () => { - const navigationEmbeddableFactory = new NavigationEmbeddableFactoryDefinition(); - const settings = navigationEmbeddableFactory.getPanelPlacementSettings( - {} as unknown as NavigationEmbeddableInput, - { - layout: 'vertical', - links: [ - { type: 'dashboardLink', destination: 'superDashboard1' }, - { type: 'dashboardLink', destination: 'superDashboard2' }, - { type: 'dashboardLink', destination: 'superDashboard3' }, - ], - } - ); - expect(settings.height).toBe(7); // 4 base plus 3 for each link. - expect(settings.width).toBe(8); - expect(settings.strategy).toBe('placeAtTop'); - }); -}); diff --git a/src/plugins/navigation_embeddable/public/index.ts b/src/plugins/navigation_embeddable/public/index.ts deleted file mode 100644 index d1655bd9bc25d..0000000000000 --- a/src/plugins/navigation_embeddable/public/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export type { NavigationEmbeddableFactory } from './embeddable'; -export { NavigationEmbeddableFactoryDefinition, NavigationEmbeddable } from './embeddable'; - -import { NavigationEmbeddablePlugin } from './plugin'; - -export function plugin() { - return new NavigationEmbeddablePlugin(); -} diff --git a/tsconfig.base.json b/tsconfig.base.json index 130c6e525fefc..b39f169b5f552 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -930,6 +930,8 @@ "@kbn/license-management-plugin/*": ["x-pack/plugins/license_management/*"], "@kbn/licensing-plugin": ["x-pack/plugins/licensing"], "@kbn/licensing-plugin/*": ["x-pack/plugins/licensing/*"], + "@kbn/links-plugin": ["src/plugins/links"], + "@kbn/links-plugin/*": ["src/plugins/links/*"], "@kbn/lint-packages-cli": ["packages/kbn-lint-packages-cli"], "@kbn/lint-packages-cli/*": ["packages/kbn-lint-packages-cli/*"], "@kbn/lint-ts-projects-cli": ["packages/kbn-lint-ts-projects-cli"], @@ -1042,8 +1044,6 @@ "@kbn/monitoring-collection-plugin/*": ["x-pack/plugins/monitoring_collection/*"], "@kbn/monitoring-plugin": ["x-pack/plugins/monitoring"], "@kbn/monitoring-plugin/*": ["x-pack/plugins/monitoring/*"], - "@kbn/navigation-embeddable-plugin": ["src/plugins/navigation_embeddable"], - "@kbn/navigation-embeddable-plugin/*": ["src/plugins/navigation_embeddable/*"], "@kbn/navigation-plugin": ["src/plugins/navigation"], "@kbn/navigation-plugin/*": ["src/plugins/navigation/*"], "@kbn/newsfeed-plugin": ["src/plugins/newsfeed"], diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx index 5e68a34928ddc..87a575ee44b12 100644 --- a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx +++ b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx @@ -63,7 +63,7 @@ export const AddEmbeddableFlyout: FC = ({ const availableSavedObjects = Array.from(embeddableFactories) .filter( (factory) => - factory.type !== 'navigation_embeddable' && // Links panels only exist on Dashboards + factory.type !== 'links' && // Links panels only exist on Dashboards (isByValueEnabled || availableEmbeddables.includes(factory.type)) ) .map((factory) => factory.savedObjectMetaData) diff --git a/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx index fa656645e5f4e..dbcd3b9cd2786 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx @@ -142,7 +142,7 @@ export const EditorMenu: FC = ({ addElement }) => { isEditable && !isContainerType && canCreateNew() && - !['visualization', 'ml', 'navigation_embeddable'].some((factoryType) => { + !['visualization', 'ml', 'links'].some((factoryType) => { return type.includes(factoryType); }) ) diff --git a/yarn.lock b/yarn.lock index 2c262676fe508..62b09c3a04d9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4803,6 +4803,10 @@ version "0.0.0" uid "" +"@kbn/links-plugin@link:src/plugins/links": + version "0.0.0" + uid "" + "@kbn/lint-packages-cli@link:packages/kbn-lint-packages-cli": version "0.0.0" uid "" @@ -5027,10 +5031,6 @@ version "0.0.0" uid "" -"@kbn/navigation-embeddable-plugin@link:src/plugins/navigation_embeddable": - version "0.0.0" - uid "" - "@kbn/navigation-plugin@link:src/plugins/navigation": version "0.0.0" uid "" From 7a3c8efe0c05b47eda9e079512c152a2935e2d40 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 25 Sep 2023 08:57:53 -0600 Subject: [PATCH 23/53] Fix SO tests --- .../src/constants.ts | 2 +- .../src/kibana_migrator_utils.fixtures.ts | 34 +++++++++---------- .../group2/check_registered_types.test.ts | 2 +- .../group3/type_registrations.test.ts | 1 + .../group5/dot_kibana_split.test.ts | 2 +- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts index f92a2b2634b78..b641c45aa72d1 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts @@ -71,13 +71,13 @@ export const DEFAULT_INDEX_TYPES_MAP: IndexTypesMap = { 'legacy-url-alias', 'lens', 'lens-ui-telemetry', + 'links', 'map', 'metrics-explorer-view', 'ml-job', 'ml-module', 'ml-trained-model', 'monitoring-telemetry', - 'links', 'osquery-manager-usage-metric', 'osquery-pack', 'osquery-pack-asset', diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts index 7e40132ec92fd..73b0ff4be5fc8 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts @@ -1478,44 +1478,44 @@ export const INDEX_MAP_BEFORE_SPLIT: IndexMap = { }, }, }, - map: { + links: { properties: { - description: { + id: { type: 'text', }, title: { type: 'text', }, - version: { - type: 'integer', - }, - mapStateJSON: { - type: 'text', - }, - layerListJSON: { - type: 'text', - }, - uiStateJSON: { + description: { type: 'text', }, - bounds: { + links: { dynamic: false, properties: {}, }, }, }, - links: { + map: { properties: { - id: { + description: { type: 'text', }, title: { type: 'text', }, - description: { + version: { + type: 'integer', + }, + mapStateJSON: { type: 'text', }, - links: { + layerListJSON: { + type: 'text', + }, + uiStateJSON: { + type: 'text', + }, + bounds: { dynamic: false, properties: {}, }, diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index d7bb9e2e9d86e..ac1f453de2b9e 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -113,7 +113,7 @@ describe('checking migration metadata changes on all registered SO types', () => "legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8", "lens": "5cfa2c52b979b4f8df56dd13c477e152183468b9", "lens-ui-telemetry": "8c47a9e393861f76e268345ecbadfc8a5fb1e0bd", - "links": "de71a127ed325261ca6bc926d93c4cd676d17a05", + "links": "a2c467f8b00e851d343dace3ef532d7fb29462e7", "maintenance-window": "d893544460abad56ff7a0e25b78f78776dfe10d1", "map": "76c71023bd198fb6b1163b31bafd926fe2ceb9da", "metrics-data-source": "81b69dc9830699d9ead5ac8dcb9264612e2a3c89", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts index 1809437f3cdcd..efb439e058cc2 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts @@ -81,6 +81,7 @@ const previouslyRegisteredTypes = [ 'legacy-url-alias', 'lens', 'lens-ui-telemetry', + 'links', 'maintenance-window', 'map', 'maps-telemetry', diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index b4edc5991d9be..3c41eafb6102c 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -233,6 +233,7 @@ describe('split .kibana index into multiple system indices', () => { "legacy-url-alias", "lens", "lens-ui-telemetry", + "links", "maintenance-window", "map", "metrics-data-source", @@ -241,7 +242,6 @@ describe('split .kibana index into multiple system indices', () => { "ml-module", "ml-trained-model", "monitoring-telemetry", - "links", "observability-onboarding-state", "osquery-manager-usage-metric", "osquery-pack", From 3da4878314016a4aedde7024bb448a7ca9432b30 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Mon, 25 Sep 2023 16:25:15 -0400 Subject: [PATCH 24/53] Fix bad functional test fixture --- .../dashboard/current/kibana.json | 165 +++++++++++++++++- 1 file changed, 163 insertions(+), 2 deletions(-) diff --git a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json index 1af0e682d585c..6b82afb60d841 100644 --- a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json +++ b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json @@ -2288,7 +2288,7 @@ "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" }, "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", "refreshInterval": { "display": "Off", "pause": false, @@ -2743,4 +2743,165 @@ "dashboard": "8.6.0" }, "coreMigrationVersion": "8.6.0" -} \ No newline at end of file +} + +{ + "id": "16e12160-5bc2-11ee-9a85-7b86504227bc", + "type": "links", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-25T16:40:49.785Z", + "created_at": "2023-09-25T16:39:26.070Z", + "version": "WzEyMzgsMV0=", + "attributes": { + "links": [ + { + "type": "dashboardLink", + "id": "link01", + "order": 0, + "destinationRefName": "link_link01_dashboard" + }, + { + "type": "dashboardLink", + "id": "link02", + "order": 1, + "destinationRefName": "link_link02_dashboard" + }, + { + "type": "dashboardLink", + "id": "link03", + "order": 2, + "destinationRefName": "link_link03_dashboard" + } + ], + "layout": "horizontal", + "title": "a few links", + "description": "" + }, + "references": [ + { + "name": "link_link01_dashboard", + "type": "dashboard", + "id": "0930f310-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link02_dashboard", + "type": "dashboard", + "id": "24751520-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link03_dashboard", + "type": "dashboard", + "id": "27398c50-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0" +} + +{ + "id": "0930f310-5bc2-11ee-9a85-7b86504227bc", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-25T16:39:42.362Z", + "created_at": "2023-09-25T16:39:42.362Z", + "version": "WzEyMzMsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "description": "", + "timeRestore": false, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":4,\"i\":\"26533acc-3516-445c-9c05-3b0e18686f38\"},\"panelIndex\":\"26533acc-3516-445c-9c05-3b0e18686f38\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_26533acc-3516-445c-9c05-3b0e18686f38\"}]", + "title": "links 001" + }, + "references": [ + { + "name": "26533acc-3516-445c-9c05-3b0e18686f38:panel_26533acc-3516-445c-9c05-3b0e18686f38", + "type": "links", + "id": "16e12160-5bc2-11ee-9a85-7b86504227bc" + }, + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} + +{ + "id": "24751520-5bc2-11ee-9a85-7b86504227bc", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-25T16:39:48.850Z", + "created_at": "2023-09-25T16:39:48.850Z", + "version": "WzEyMzQsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "description": "", + "timeRestore": false, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":4,\"i\":\"26533acc-3516-445c-9c05-3b0e18686f38\"},\"panelIndex\":\"26533acc-3516-445c-9c05-3b0e18686f38\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_26533acc-3516-445c-9c05-3b0e18686f38\"}]", + "title": "links 002" + }, + "references": [ + { + "name": "26533acc-3516-445c-9c05-3b0e18686f38:panel_26533acc-3516-445c-9c05-3b0e18686f38", + "type": "links", + "id": "16e12160-5bc2-11ee-9a85-7b86504227bc" + }, + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} + +{ + "id": "27398c50-5bc2-11ee-9a85-7b86504227bc", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-25T16:42:56.124Z", + "created_at": "2023-09-25T16:42:56.124Z", + "version": "WzEyMzksMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "description": "", + "timeRestore": false, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":3,\"i\":\"12e3dea4-44cc-405e-99de-408c5879ab55\"},\"panelIndex\":\"12e3dea4-44cc-405e-99de-408c5879ab55\",\"embeddableConfig\":{\"savedVis\":{\"id\":\"\",\"title\":\"\",\"description\":\"\",\"type\":\"markdown\",\"params\":{\"fontSize\":12,\"openLinksInNewTab\":false,\"markdown\":\"This panel appears at the top\"},\"uiState\":{},\"data\":{\"aggs\":[],\"searchSource\":{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}}},\"enhancements\":{}}}]", + "title": "links 003" + }, + "references": [ + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} From 933492ea1689fa701b1dc2c80b5139b980e501f6 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Mon, 25 Sep 2023 16:27:25 -0400 Subject: [PATCH 25/53] Only replace embeddable if type changes (#167070) This should fix one of the failing functional tests in the Dashboard Navigation PR. The control group editor should only call `Container.replaceEmbeddable` when the `type` has changed. If the `type` has not changed, the `newExplicitInput` are only a subset of changes. So we should call `Container.updateInputForChild`. --- .../public/control_group/actions/edit_control_flyout.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx b/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx index 95fcda036c329..9b54d83e3fb68 100644 --- a/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx +++ b/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx @@ -92,7 +92,11 @@ export const EditControlFlyout = ({ } closeFlyout(); - await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type); + if (panel.type === type) { + controlGroup.updateInputForChild(embeddable.id, inputToReturn); + } else { + await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type); + } }; return ( From 2188ec97ef3a7ee6cb225a1f3b16c91aad2ef23a Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 25 Sep 2023 15:49:22 -0600 Subject: [PATCH 26/53] [Dashboard Navigation] Mark links panel as technical preview (#167091) ## Summary This PR does two things to mark the new links panel as being under technical preview: 1. It adds an advanced setting that allows this new panel type to be disabled - doing so will prevent any new links panels from being **created**, but it will not impact any links panels that already exist: ![image](https://github.com/elastic/kibana/assets/8698078/8bdb73ba-a027-40ff-b6f6-25f59b3d3046) | When `labs:dashboard:linksPanel = true` | When `labs:dashboard:linksPanel = false` | |--------|--------| | ![image](https://github.com/elastic/kibana/assets/8698078/5e12c96a-ba87-417c-8963-c6d2838e3d55) | ![image](https://github.com/elastic/kibana/assets/8698078/5f8d6430-b9c1-4711-906e-a84ea6dfb5e5) | 2. It adds a "Technical preview" label to the first step in the flyout: ![Sep-25-2023 09-20-16](https://github.com/elastic/kibana/assets/8698078/75de02aa-e563-49cf-a57c-71b41f514a1d) I chose to add this to make it a bit more obvious that this feature is under technical preview; otherwise, the only way that users will know that it may not be stable would be through the advanced setting above (which is very hidden) or through the documentation. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../server/collectors/management/schema.ts | 4 +++ .../server/collectors/management/types.ts | 1 + .../public/components/editor/links_editor.tsx | 28 ++++++++++++---- .../links/public/components/links_strings.ts | 9 ++++++ .../embeddable/links_embeddable_factory.ts | 26 ++++++++------- src/plugins/links/public/plugin.ts | 7 ++-- .../links/public/services/kibana_services.ts | 5 ++- src/plugins/presentation_util/common/labs.ts | 32 ++++++++++++++++++- src/plugins/telemetry/schema/oss_plugins.json | 6 ++++ 9 files changed, 96 insertions(+), 22 deletions(-) diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index f17f1e1cc42e8..0f3db5fc2bbab 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -493,6 +493,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'labs:dashboard:linksPanel': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'discover:showFieldStatistics': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 902190f0cf675..fb3c31bf44d89 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -135,6 +135,7 @@ export interface UsageStats { 'labs:canvas:useDataService': boolean; 'labs:presentation:timeToPresent': boolean; 'labs:dashboard:enable_ui': boolean; + 'labs:dashboard:linksPanel': boolean; 'labs:dashboard:deferBelowFold': boolean; 'labs:dashboard:dashboardControls': boolean; 'discover:rowHeightOption': number; diff --git a/src/plugins/links/public/components/editor/links_editor.tsx b/src/plugins/links/public/components/editor/links_editor.tsx index a2a1b409b6cf5..b8f2fc50dba37 100644 --- a/src/plugins/links/public/components/editor/links_editor.tsx +++ b/src/plugins/links/public/components/editor/links_editor.tsx @@ -10,10 +10,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiForm, + EuiBadge, EuiTitle, EuiButton, EuiSwitch, EuiFormRow, + EuiToolTip, EuiFlexItem, EuiFlexGroup, EuiDroppable, @@ -153,13 +155,25 @@ const LinksEditor = ({ <>
- -

- {isEditingExisting - ? LinksStrings.editor.panelEditor.getEditFlyoutTitle() - : LinksStrings.editor.panelEditor.getCreateFlyoutTitle()} -

-
+ + + +

+ {isEditingExisting + ? LinksStrings.editor.panelEditor.getEditFlyoutTitle() + : LinksStrings.editor.panelEditor.getCreateFlyoutTitle()} +

+
+
+ + + {/* The EuiBadge needs an empty title to prevent the default tooltip */} + + {LinksStrings.editor.panelEditor.getTechnicalPreviewLabel()} + + + +
diff --git a/src/plugins/links/public/components/links_strings.ts b/src/plugins/links/public/components/links_strings.ts index 50341f9fa6a8a..4756bc28e3bdd 100644 --- a/src/plugins/links/public/components/links_strings.ts +++ b/src/plugins/links/public/components/links_strings.ts @@ -35,6 +35,15 @@ export const LinksStrings = { defaultMessage: 'Close', }), panelEditor: { + getTechnicalPreviewTooltip: () => + i18n.translate('links.panelEditor.technicalPreviewTooltip', { + defaultMessage: + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', + }), + getTechnicalPreviewLabel: () => + i18n.translate('links.panelEditor.technicalPreviewLabel', { + defaultMessage: 'Technical preview', + }), getLinksTitle: () => i18n.translate('links.panelEditor.linksTitle', { defaultMessage: 'Links', diff --git a/src/plugins/links/public/embeddable/links_embeddable_factory.ts b/src/plugins/links/public/embeddable/links_embeddable_factory.ts index c4b678238baa5..e0502d34a742c 100644 --- a/src/plugins/links/public/embeddable/links_embeddable_factory.ts +++ b/src/plugins/links/public/embeddable/links_embeddable_factory.ts @@ -11,25 +11,29 @@ import { EmbeddableFactoryDefinition, ErrorEmbeddable, } from '@kbn/embeddable-plugin/public'; -import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; -import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; - -import { IProvidesPanelPlacementSettings } from '@kbn/dashboard-plugin/public/dashboard_container/component/panel_placement/types'; -import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; import { MigrateFunctionsObject, GetMigrationFunctionObjectFn, } from '@kbn/kibana-utils-plugin/common'; -import { UiActionsPresentableGrouping } from '@kbn/ui-actions-plugin/public'; +import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; import { DASHBOARD_GRID_COLUMN_COUNT } from '@kbn/dashboard-plugin/public'; -import { LinksInput, LinksByReferenceInput, LinksEditorFlyoutReturn } from './types'; +import { UiActionsPresentableGrouping } from '@kbn/ui-actions-plugin/public'; +import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { IProvidesPanelPlacementSettings } from '@kbn/dashboard-plugin/public/dashboard_container/component/panel_placement/types'; + +import { + coreServices, + presentationUtil, + untilPluginStartServicesReady, +} from '../services/kibana_services'; import { extract, inject } from '../../common/embeddable'; -import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; import type { LinksEmbeddable } from './links_embeddable'; -import { LinksAttributes } from '../../common/content_management'; import { LinksStrings } from '../components/links_strings'; +import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; +import { LinksAttributes } from '../../common/content_management'; import { getLinksAttributeService } from '../services/attribute_service'; -import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; +import { LinksInput, LinksByReferenceInput, LinksEditorFlyoutReturn } from './types'; export type LinksFactory = EmbeddableFactory; @@ -86,7 +90,7 @@ export class LinksFactoryDefinition } public canCreateNew() { - return true; + return presentationUtil.labsService.isProjectEnabled('labs:dashboard:linksPanel'); } public getDefaultInput(): Partial { diff --git a/src/plugins/links/public/plugin.ts b/src/plugins/links/public/plugin.ts index d799213e2a18a..7927de88b80e7 100644 --- a/src/plugins/links/public/plugin.ts +++ b/src/plugins/links/public/plugin.ts @@ -13,9 +13,11 @@ import { } from '@kbn/content-management-plugin/public'; import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; + +import { APP_NAME } from '../common'; import { LinksFactoryDefinition } from './embeddable'; import { CONTENT_ID, LATEST_VERSION } from '../common'; -import { APP_NAME } from '../common'; import { setKibanaServices } from './services/kibana_services'; export interface LinksSetupDependencies { @@ -25,8 +27,9 @@ export interface LinksSetupDependencies { export interface LinksStartDependencies { embeddable: EmbeddableStart; - contentManagement: ContentManagementPublicStart; dashboard: DashboardStart; + presentationUtil: PresentationUtilPluginStart; + contentManagement: ContentManagementPublicStart; } export class LinksPlugin diff --git a/src/plugins/links/public/services/kibana_services.ts b/src/plugins/links/public/services/kibana_services.ts index 88b700df931db..76acd242f7575 100644 --- a/src/plugins/links/public/services/kibana_services.ts +++ b/src/plugins/links/public/services/kibana_services.ts @@ -10,14 +10,16 @@ import { BehaviorSubject } from 'rxjs'; import { CoreStart } from '@kbn/core/public'; import { DashboardStart } from '@kbn/dashboard-plugin/public'; +import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; -import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import { LinksStartDependencies } from '../plugin'; export let coreServices: CoreStart; export let dashboardServices: DashboardStart; export let embeddableService: EmbeddableStart; +export let presentationUtil: PresentationUtilPluginStart; export let contentManagement: ContentManagementPublicStart; const servicesReady$ = new BehaviorSubject(false); @@ -38,6 +40,7 @@ export const setKibanaServices = (kibanaCore: CoreStart, deps: LinksStartDepende coreServices = kibanaCore; dashboardServices = deps.dashboard; embeddableService = deps.embeddable; + presentationUtil = deps.presentationUtil; contentManagement = deps.contentManagement; servicesReady$.next(true); diff --git a/src/plugins/presentation_util/common/labs.ts b/src/plugins/presentation_util/common/labs.ts index b900eb7a6e36c..18fd77a364429 100644 --- a/src/plugins/presentation_util/common/labs.ts +++ b/src/plugins/presentation_util/common/labs.ts @@ -10,13 +10,26 @@ import { i18n } from '@kbn/i18n'; export const LABS_PROJECT_PREFIX = 'labs:'; export const DEFER_BELOW_FOLD = `${LABS_PROJECT_PREFIX}dashboard:deferBelowFold` as const; +export const DASHBOARD_LINKS_PANEL = `${LABS_PROJECT_PREFIX}dashboard:linksPanel` as const; export const DASHBOARD_CONTROLS = `${LABS_PROJECT_PREFIX}dashboard:dashboardControls` as const; export const BY_VALUE_EMBEDDABLE = `${LABS_PROJECT_PREFIX}canvas:byValueEmbeddable` as const; -export const projectIDs = [DEFER_BELOW_FOLD, DASHBOARD_CONTROLS, BY_VALUE_EMBEDDABLE] as const; +export const projectIDs = [ + DEFER_BELOW_FOLD, + DASHBOARD_CONTROLS, + BY_VALUE_EMBEDDABLE, + DASHBOARD_LINKS_PANEL, +] as const; export const environmentNames = ['kibana', 'browser', 'session'] as const; export const solutionNames = ['canvas', 'dashboard', 'presentation'] as const; +const technicalPreviewLabel = i18n.translate( + 'presentationUtil.advancedSettings.technicalPreviewLabel', + { + defaultMessage: 'technical preview', + } +); + /** * This is a list of active Labs Projects for the Presentation Team. It is the "source of truth" for all projects * provided to users of our solutions in Kibana. @@ -50,6 +63,23 @@ export const projects: { [ID in ProjectID]: ProjectConfig & { id: ID } } = { }), solutions: ['dashboard'], }, + [DASHBOARD_LINKS_PANEL]: { + id: DASHBOARD_LINKS_PANEL, + isActive: true, + isDisplayed: true, + environments: ['kibana', 'browser', 'session'], + name: i18n.translate('presentationUtil.labs.enableLinksPanelProjectName', { + defaultMessage: 'Enable links panel', + }), + description: i18n.translate('presentationUtil.labs.enableLinksPanelProjectDescription', { + defaultMessage: + '{technicalPreviewLabel} Enables the links panel for dashboard, which allows dashboard authors to easily link dashboards together.', + values: { + technicalPreviewLabel: `[${technicalPreviewLabel}]`, + }, + }), + solutions: ['dashboard'], + }, [BY_VALUE_EMBEDDABLE]: { id: BY_VALUE_EMBEDDABLE, isActive: true, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index bc8686101eb21..e2013ff091c76 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -9929,6 +9929,12 @@ "description": "Non-default value of setting." } }, + "labs:dashboard:linksPanel": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "discover:showFieldStatistics": { "type": "boolean", "_meta": { From d83a3dd26440dcebf612afff74c2d66d4d84f3a8 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 26 Sep 2023 08:39:27 -0600 Subject: [PATCH 27/53] Update scrolling screenshot to actual last panel --- .../dashboard_embed_mode_scrolling.png | Bin 92765 -> 57005 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/functional/screenshots/baseline/dashboard_embed_mode_scrolling.png b/test/functional/screenshots/baseline/dashboard_embed_mode_scrolling.png index eed23bc7ed78f3860e11ba9461b54900c881ea81..da5debd6d051cfb0d43dd5ee2039046953f7c850 100644 GIT binary patch literal 57005 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

VODJ0zTwDDmiD)mK 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

x&q45Y9%11X7p6l{=Zxh>6h(R!v+p^2N1NC;A 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

|IQcC~~4SpP{B!(u0tz#9Hc+SoD!yF+=e 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⁢X_d&AjzR5)!qRd?B--D8dHZj#u2nfR>5ek> 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 Date: Tue, 26 Sep 2023 09:58:37 -0600 Subject: [PATCH 28/53] Fix linting that wasn't failing before --- .../component/grid/dashboard_grid_item.test.tsx | 2 +- .../dashboard_container/embeddable/create/create_dashboard.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx index fa26677ba1f17..66e2c7c8f83ea 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx @@ -43,7 +43,7 @@ const createAndMountDashboardGridItem = (props: DashboardGridItemProps) => { explicitInput: { id: '2' }, }, }; - const dashboardContainer = buildMockDashboard({ panels }); + const dashboardContainer = buildMockDashboard({ overrides: { panels } }); const component = mountWithIntl( diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index 70e81ca7a76d1..e843d07ad6ff1 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -25,9 +25,9 @@ import { pluginServices } from '../../../services/plugin_services'; import { DashboardCreationOptions } from '../dashboard_container_factory'; import { DashboardContainerInput, DashboardPanelState } from '../../../../common'; import { startSyncingDashboardDataViews } from './data_views/sync_dashboard_data_views'; -import { findTopLeftMostOpenSpace } from '../../component/panel/dashboard_panel_placement'; import { LoadDashboardReturn } from '../../../services/dashboard_content_management/types'; import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_search_state'; +import { panelPlacementStrategies } from '../../component/panel_placement/place_new_panel_strategies'; import { DEFAULT_DASHBOARD_INPUT, DEFAULT_PANEL_HEIGHT, @@ -297,6 +297,7 @@ export const initializeDashboard = async ({ const { width, height } = incomingEmbeddable.size; const currentPanels = container.getInput().panels; const embeddableId = incomingEmbeddable.embeddableId ?? v4(); + const { findTopLeftMostOpenSpace } = panelPlacementStrategies; const { newPanelPlacement } = findTopLeftMostOpenSpace({ width: width ?? DEFAULT_PANEL_WIDTH, height: height ?? DEFAULT_PANEL_HEIGHT, From b2f2da6f47be9e8ecaf9a89a800d0bf4dc6ef464 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 26 Sep 2023 10:17:28 -0600 Subject: [PATCH 29/53] More linting fixes --- src/plugins/links/public/mocks.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugins/links/public/mocks.tsx b/src/plugins/links/public/mocks.tsx index 7268893babcba..6a27185c9b09a 100644 --- a/src/plugins/links/public/mocks.tsx +++ b/src/plugins/links/public/mocks.tsx @@ -10,6 +10,7 @@ import { coreMock } from '@kbn/core/public/mocks'; import { dashboardPluginMock } from '@kbn/dashboard-plugin/public/mocks'; import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; +import { presentationUtilPluginMock } from '@kbn/presentation-util-plugin/public/mocks'; import { setKibanaServices } from './services/kibana_services'; export const setStubKibanaServices = () => { @@ -19,5 +20,6 @@ export const setStubKibanaServices = () => { dashboard: dashboardPluginMock.createStartContract(), embeddable: embeddablePluginMock.createStartContract(), contentManagement: contentManagementMock.createStartContract(), + presentationUtil: presentationUtilPluginMock.createStartContract(core), }); }; From 7df59e1481bffb05896b6170ee6a2fd5d0afe1a1 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 26 Sep 2023 10:48:20 -0600 Subject: [PATCH 30/53] Fix `copy_panel_to` unused import --- test/functional/apps/dashboard/group3/copy_panel_to.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/functional/apps/dashboard/group3/copy_panel_to.ts b/test/functional/apps/dashboard/group3/copy_panel_to.ts index dbafa5d68b5e8..3c6fa6d790eaf 100644 --- a/test/functional/apps/dashboard/group3/copy_panel_to.ts +++ b/test/functional/apps/dashboard/group3/copy_panel_to.ts @@ -14,7 +14,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardPanelActions = getService('dashboardPanelActions'); const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); - const find = getService('find'); const PageObjects = getPageObjects([ 'header', From d6b1d9ea365a7186699170ddbf7fc19e558a08fd Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 27 Sep 2023 09:40:28 -0600 Subject: [PATCH 31/53] Another round of `Check Types Commit Diff` fixes --- .../canvas/common/lib/embeddable_dataurl.ts | 6 ++- .../dashboard_drilldown_config.story.tsx | 40 ++++++++++--------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/canvas/common/lib/embeddable_dataurl.ts b/x-pack/plugins/canvas/common/lib/embeddable_dataurl.ts index e76dedfe63b14..96e77a54e5398 100644 --- a/x-pack/plugins/canvas/common/lib/embeddable_dataurl.ts +++ b/x-pack/plugins/canvas/common/lib/embeddable_dataurl.ts @@ -5,9 +5,11 @@ * 2.0. */ +import { type ExplicitInputWithAttributes } from '@kbn/embeddable-plugin/public/lib'; import { EmbeddableInput } from '../../types'; -export const encode = (input: Partial) => - Buffer.from(JSON.stringify(input)).toString('base64'); +export const encode = ( + input: ExplicitInputWithAttributes | Partial | Readonly +) => Buffer.from(JSON.stringify(input)).toString('base64'); export const decode = (serializedInput: string) => JSON.parse(Buffer.from(serializedInput, 'base64').toString()); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx index 0c549f76b4ff4..0dcd18e526bf0 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx @@ -9,7 +9,13 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; +import { + DashboardDrilldownOptions, + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, +} from '@kbn/presentation-util-plugin/public'; + import { DashboardDrilldownConfig } from './dashboard_drilldown_config'; +import { Config as DrilldownConfig } from '../../types'; export const dashboards = [ { value: 'dashboard1', label: 'Dashboard 1' }, @@ -19,18 +25,24 @@ export const dashboards = [ const InteractiveDemo: React.FC = () => { const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1'); - const [currentFilters, setCurrentFilters] = React.useState(false); - const [keepRange, setKeepRange] = React.useState(false); + const [options, setOptions] = React.useState( + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS + ); + // const [currentFilters, setCurrentFilters] = React.useState(false); + // const [keepRange, setKeepRange] = React.useState(false); return ( { + if (changes.dashboardId) { + setActiveDashboardId(changes.dashboardId); + delete changes.dashboardId; + } + setOptions({ ...options, ...changes }); + }} onDashboardSelect={(id) => setActiveDashboardId(id)} - onCurrentFiltersToggle={() => setCurrentFilters((old) => !old)} - onKeepRangeToggle={() => setKeepRange((old) => !old)} onSearchChange={() => {}} isLoading={false} /> @@ -41,23 +53,13 @@ storiesOf( 'services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config', module ) - .add('default', () => ( - console.log('onDashboardSelect', e)} - onSearchChange={() => {}} - isLoading={false} - /> - )) .add('with switches', () => ( console.log('onDashboardSelect', e)} - onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')} - onKeepRangeToggle={() => console.log('onKeepRangeToggle')} onSearchChange={() => {}} + onConfigChange={(e) => console.log('onConfigChange', e)} isLoading={false} /> )) From 00a7765a5e129ee4d32fd0976dc0f43d2d6ecb98 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 27 Sep 2023 15:46:51 +0000 Subject: [PATCH 32/53] [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' --- .../dashboard_drilldown_config.story.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx index 0dcd18e526bf0..12eb0203f0fbb 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx @@ -15,7 +15,6 @@ import { } from '@kbn/presentation-util-plugin/public'; import { DashboardDrilldownConfig } from './dashboard_drilldown_config'; -import { Config as DrilldownConfig } from '../../types'; export const dashboards = [ { value: 'dashboard1', label: 'Dashboard 1' }, From a208fcab6631e613673073d253b66457d69049aa Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 27 Sep 2023 10:00:20 -0600 Subject: [PATCH 33/53] Delete broken storybook --- .../dashboard_drilldown_config.story.tsx | 65 ------------------- 1 file changed, 65 deletions(-) delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx deleted file mode 100644 index 12eb0203f0fbb..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { - DashboardDrilldownOptions, - DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, -} from '@kbn/presentation-util-plugin/public'; - -import { DashboardDrilldownConfig } from './dashboard_drilldown_config'; - -export const dashboards = [ - { value: 'dashboard1', label: 'Dashboard 1' }, - { value: 'dashboard2', label: 'Dashboard 2' }, - { value: 'dashboard3', label: 'Dashboard 3' }, -]; - -const InteractiveDemo: React.FC = () => { - const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1'); - const [options, setOptions] = React.useState( - DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS - ); - // const [currentFilters, setCurrentFilters] = React.useState(false); - // const [keepRange, setKeepRange] = React.useState(false); - - return ( - { - if (changes.dashboardId) { - setActiveDashboardId(changes.dashboardId); - delete changes.dashboardId; - } - setOptions({ ...options, ...changes }); - }} - onDashboardSelect={(id) => setActiveDashboardId(id)} - onSearchChange={() => {}} - isLoading={false} - /> - ); -}; - -storiesOf( - 'services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config', - module -) - .add('with switches', () => ( - console.log('onDashboardSelect', e)} - onSearchChange={() => {}} - onConfigChange={(e) => console.log('onConfigChange', e)} - isLoading={false} - /> - )) - .add('interactive demo', () => ); From 79e02d1b9c0e98e5d44a05105d7d514fe3f0a97b Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Wed, 27 Sep 2023 13:36:54 -0400 Subject: [PATCH 34/53] More descriptive title for save modal --- .../public/content_management/save_to_library.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/plugins/links/public/content_management/save_to_library.tsx b/src/plugins/links/public/content_management/save_to_library.tsx index b8014780894f0..6bb00217224cb 100644 --- a/src/plugins/links/public/content_management/save_to_library.tsx +++ b/src/plugins/links/public/content_management/save_to_library.tsx @@ -7,6 +7,7 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { showSaveModal, OnSaveProps, @@ -14,12 +15,19 @@ import { SaveResult, } from '@kbn/saved-objects-plugin/public'; -import { APP_NAME } from '../../common'; +import { CONTENT_ID } from '../../common'; import { LinksAttributes } from '../../common/content_management'; import { LinksByReferenceInput, LinksInput } from '../embeddable/types'; import { checkForDuplicateTitle } from './duplicate_title_check'; import { getLinksAttributeService } from '../services/attribute_service'; +const modalTitle = i18n.translate('links.contentManagement.saveModalTitle', { + defaultMessage: `Save {contentId} panel to library`, + values: { + contentId: CONTENT_ID, + }, +}); + export const runSaveToLibrary = async ( newAttributes: LinksAttributes, initialInput: LinksInput @@ -68,10 +76,11 @@ export const runSaveToLibrary = async ( onSave={onSave} onClose={() => resolve(undefined)} title={newAttributes.title} + customModalTitle={modalTitle} description={newAttributes.description} showDescription showCopyOnSave={false} - objectType={APP_NAME} + objectType={CONTENT_ID} /> ); showSaveModal(saveModal); From 881902e46989499dbd49b0c3dc439ff2c13e1556 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 27 Sep 2023 13:16:22 -0600 Subject: [PATCH 35/53] Fix layout problems --- .../dashboard_container/component/grid/dashboard_grid.tsx | 4 +++- .../component/viewport/dashboard_viewport.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx index 51cc24dd37029..868bd3d535aa1 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx @@ -88,6 +88,8 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => { const onLayoutChange = useCallback( (newLayout: Array) => { + if (viewMode !== ViewMode.EDIT) return; + const updatedPanels: { [key: string]: DashboardPanelState } = newLayout.reduce( (updatedPanelsAcc, panelLayout) => { updatedPanelsAcc[panelLayout.i] = { @@ -102,7 +104,7 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => { dashboard.dispatch.setPanels(updatedPanels); } }, - [dashboard, panels] + [dashboard, panels, viewMode] ); const classes = classNames({ diff --git a/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx index d1931d7ed3e61..2af63836fb011 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx @@ -86,7 +86,7 @@ export const DashboardViewportComponent = () => { data-description={description} data-shared-items-count={panelCount} > - + {viewportWidth !== 0 && }

); From 2329a6bd82f202b96f05d78b26d2e82f580c2b62 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 27 Sep 2023 13:24:52 -0600 Subject: [PATCH 36/53] Add a clarifying comment --- .../component/viewport/dashboard_viewport.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx index 2af63836fb011..e37f14ee5b977 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx @@ -86,6 +86,8 @@ export const DashboardViewportComponent = () => { data-description={description} data-shared-items-count={panelCount} > + {/* Wait for `viewportWidth` to actually be set before rendering the dashboard grid - + otherwise, there is a race condition where the panels can end up being squashed */} {viewportWidth !== 0 && }

From b9e5193b7b66103613b263d426440d9626e1f2fe Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 27 Sep 2023 14:34:45 -0600 Subject: [PATCH 37/53] Undo screenshot change --- .../dashboard_embed_mode_scrolling.png | Bin 57005 -> 83762 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/functional/screenshots/baseline/dashboard_embed_mode_scrolling.png b/test/functional/screenshots/baseline/dashboard_embed_mode_scrolling.png index da5debd6d051cfb0d43dd5ee2039046953f7c850..a98a60b96ea68b65289c981bef454fca61e418b3 100644 GIT binary patch literal 83762 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

VODJ0zTwDDmiD)mK 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

x&q45Y9%11X7p6l{=Zxh>6h(R!v+p^2N1NC;A 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 Date: Thu, 28 Sep 2023 14:19:03 +0200 Subject: [PATCH 38/53] Remove 'links' definition from pre 8.8.0 maps --- .../src/constants.ts | 1 - .../src/kibana_migrator_utils.fixtures.ts | 17 ----------------- .../migrations/group5/dot_kibana_split.test.ts | 1 - 3 files changed, 19 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts index b641c45aa72d1..c4a3018fdfb37 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts @@ -71,7 +71,6 @@ export const DEFAULT_INDEX_TYPES_MAP: IndexTypesMap = { 'legacy-url-alias', 'lens', 'lens-ui-telemetry', - 'links', 'map', 'metrics-explorer-view', 'ml-job', diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts index 73b0ff4be5fc8..9100f489bef42 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts @@ -1478,23 +1478,6 @@ export const INDEX_MAP_BEFORE_SPLIT: IndexMap = { }, }, }, - links: { - properties: { - id: { - type: 'text', - }, - title: { - type: 'text', - }, - description: { - type: 'text', - }, - links: { - dynamic: false, - properties: {}, - }, - }, - }, map: { properties: { description: { diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index 6a4bc23a814b1..25dc5a46a6793 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -438,7 +438,6 @@ describe('split .kibana index into multiple system indices', () => { "legacy-url-alias", "lens", "lens-ui-telemetry", - "links", "maintenance-window", "map", "metrics-data-source", From 8648debc45c1aa3123b998ec9fe6ced8cdcdf109 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 28 Sep 2023 10:29:35 -0400 Subject: [PATCH 39/53] Fix saved object namespace type --- src/plugins/links/server/saved_objects/links.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/links/server/saved_objects/links.ts b/src/plugins/links/server/saved_objects/links.ts index 1dd0c8e618a59..b00f49e6d8cae 100644 --- a/src/plugins/links/server/saved_objects/links.ts +++ b/src/plugins/links/server/saved_objects/links.ts @@ -14,7 +14,7 @@ export const linksSavedObjectType: SavedObjectsType = { name: CONTENT_ID, indexPattern: ANALYTICS_SAVED_OBJECT_INDEX, hidden: false, - namespaceType: 'multiple', + namespaceType: 'multiple-isolated', management: { icon: APP_ICON, defaultSearchField: 'title', From 9de245bd0338bbfa85d74fbc7ae6bcc10a08ff82 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 28 Sep 2023 11:00:29 -0400 Subject: [PATCH 40/53] Fix saved object integration test --- .../saved_objects/migrations/group5/dot_kibana_split.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index 25dc5a46a6793..6a4bc23a814b1 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -438,6 +438,7 @@ describe('split .kibana index into multiple system indices', () => { "legacy-url-alias", "lens", "lens-ui-telemetry", + "links", "maintenance-window", "map", "metrics-data-source", From 4fc73af1da65e5dd20169be501346e36df16bfb9 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 28 Sep 2023 11:17:39 -0600 Subject: [PATCH 41/53] Update serverless saved object --- .../dashboards/exports/serverless_dashboard_8_11.ndjson | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson b/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson index 87cb044789f7e..f68023c5d4538 100644 --- a/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson +++ b/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson @@ -1,5 +1,5 @@ -{"attributes":{"fields":"[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]","timeFieldName":"@timestamp","title":"logstash-*"},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T14:45:18.313Z","id":"logstash-*","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"7.11.0","updated_at":"2023-08-25T14:45:18.313Z","version":"WzE0MSwxXQ=="} -{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"4ba1a1be-6e67-434b-b3a0-f30db8ea5395":{"columnOrder":["7a5d833b-ca6f-4e48-a924-d2a28d365dc3","3cf18f28-3495-4d45-a55f-d97f88022099","3dc0bd55-2087-4e60-aea2-f9910714f7db"],"columns":{"3cf18f28-3495-4d45-a55f-d97f88022099":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"includeEmptyRows":false,"interval":"auto"},"scale":"interval","sourceField":"@timestamp"},"3dc0bd55-2087-4e60-aea2-f9910714f7db":{"dataType":"number","isBucketed":false,"label":"Average of bytes","operationType":"average","scale":"ratio","sourceField":"bytes"},"7a5d833b-ca6f-4e48-a924-d2a28d365dc3":{"dataType":"ip","isBucketed":true,"label":"Top values of ip","operationType":"terms","params":{"orderBy":{"columnId":"3dc0bd55-2087-4e60-aea2-f9910714f7db","type":"column"},"orderDirection":"desc","size":3},"scale":"ordinal","sourceField":"ip"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"layers":[{"accessors":["3dc0bd55-2087-4e60-aea2-f9910714f7db"],"layerId":"4ba1a1be-6e67-434b-b3a0-f30db8ea5395","seriesType":"bar_stacked","splitAccessor":"7a5d833b-ca6f-4e48-a924-d2a28d365dc3","xAccessor":"3cf18f28-3495-4d45-a55f-d97f88022099"}],"legend":{"isVisible":true,"legendSize":"auto","position":"right"},"preferredSeriesType":"bar_stacked"}},"title":"lnsXYvis","visualizationType":"lnsXY"},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T14:45:18.313Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-4ba1a1be-6e67-434b-b3a0-f30db8ea5395","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-08-25T14:45:18.313Z","version":"WzE0MywxXQ=="} -{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"c61a8afb-a185-4fae-a064-fb3846f6c451":{"columnOrder":["2cd09808-3915-49f4-b3b0-82767eba23f7"],"columns":{"2cd09808-3915-49f4-b3b0-82767eba23f7":{"dataType":"number","isBucketed":false,"label":"Maximum of bytes","operationType":"max","scale":"ratio","sourceField":"bytes"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"accessor":"2cd09808-3915-49f4-b3b0-82767eba23f7","isHorizontal":false,"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","layers":[{"accessors":["d3e62a7a-c259-4fff-a2fc-eebf20b7008a","26ef70a9-c837-444c-886e-6bd905ee7335"],"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","seriesType":"area","splitAccessor":"54cd64ed-2a44-4591-af84-b2624504569a","xAccessor":"d6e40cea-6299-43b4-9c9d-b4ee305a2ce8"}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"area","size":"xl","textAlign":"center","titlePosition":"bottom"}},"title":"Artistpreviouslyknownaslens","visualizationType":"lnsLegacyMetric"},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T14:45:18.313Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-c61a8afb-a185-4fae-a064-fb3846f6c451","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-08-25T14:45:18.313Z","version":"WzE0MiwxXQ=="} -{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":true,\"alias\":null,\"key\":\"agent.raw\",\"field\":\"agent.raw\",\"params\":{\"query\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"agent.raw\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"}},\"$state\":{\"store\":\"appState\"}}]}"},"optionsJSON":"{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}","panelsJSON":"[{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":12,\"h\":13,\"i\":\"5b087cde-634a-4815-9093-71891a900380\"},\"panelIndex\":\"5b087cde-634a-4815-9093-71891a900380\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65\"}],\"state\":{\"visualization\":{\"shape\":\"donut\",\"layers\":[{\"layerId\":\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\",\"primaryGroups\":[\"fbf03774-001d-4032-a808-004140e94918\"],\"metrics\":[\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"default\",\"nestedLegend\":false,\"layerType\":\"data\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\":{\"columns\":{\"fbf03774-001d-4032-a808-004140e94918\":{\"label\":\"Top 3 values of ip\",\"dataType\":\"ip\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"ip\",\"isBucketed\":true,\"params\":{\"size\":3,\"orderBy\":{\"type\":\"column\",\"columnId\":\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false}},\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\":{\"label\":\"Average of bytes\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"bytes\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"fbf03774-001d-4032-a808-004140e94918\",\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{},\"hidePanelTitles\":false},\"title\":\"Custom Title\"},{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":12,\"y\":1,\"w\":36,\"h\":13,\"i\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\"},\"panelIndex\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"description\":\"Wow what a neat description\",\"enhancements\":{}},\"title\":\"Custom Title on a Library Item\",\"panelRefName\":\"panel_ee9dceec-afc3-4258-9998-44a00c2b36fc\"},{\"version\":\"8.11.0\",\"type\":\"map\",\"gridData\":{\"x\":0,\"y\":14,\"w\":38,\"h\":16,\"i\":\"7557df66-cfde-4401-a926-aff27d774715\"},\"panelIndex\":\"7557df66-cfde-4401-a926-aff27d774715\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"layerListJSON\":\"[{\\\"locale\\\":\\\"autoselect\\\",\\\"sourceDescriptor\\\":{\\\"type\\\":\\\"EMS_TMS\\\",\\\"isAutoSelect\\\":true,\\\"lightModeDefault\\\":\\\"road_map_desaturated\\\"},\\\"id\\\":\\\"710998eb-fda1-462c-80f2-d498df132ebf\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"EMS_VECTOR_TILE\\\",\\\"color\\\":\\\"\\\"},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"EMS_VECTOR_TILE\\\"},{\\\"sourceDescriptor\\\":{\\\"geoField\\\":\\\"geo.coordinates\\\",\\\"scalingType\\\":\\\"MVT\\\",\\\"id\\\":\\\"4ed5225d-d42d-40e3-9515-5a918208db27\\\",\\\"type\\\":\\\"ES_SEARCH\\\",\\\"applyGlobalQuery\\\":true,\\\"applyGlobalTime\\\":true,\\\"applyForceRefresh\\\":true,\\\"filterByMapBounds\\\":true,\\\"tooltipProperties\\\":[],\\\"sortField\\\":\\\"\\\",\\\"sortOrder\\\":\\\"desc\\\",\\\"topHitsGroupByTimeseries\\\":false,\\\"topHitsSplitField\\\":\\\"\\\",\\\"topHitsSize\\\":1,\\\"indexPatternRefName\\\":\\\"layer_1_source_index_pattern\\\"},\\\"id\\\":\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"VECTOR\\\",\\\"properties\\\":{\\\"icon\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"marker\\\"}},\\\"fillColor\\\":{\\\"type\\\":\\\"DYNAMIC\\\",\\\"options\\\":{\\\"color\\\":\\\"Blues\\\",\\\"colorCategory\\\":\\\"palette_0\\\",\\\"field\\\":{\\\"name\\\":\\\"extension.raw\\\",\\\"origin\\\":\\\"source\\\"},\\\"fieldMetaOptions\\\":{\\\"isEnabled\\\":true,\\\"sigma\\\":3},\\\"type\\\":\\\"CATEGORICAL\\\",\\\"otherCategoryColor\\\":\\\"#000000\\\"}},\\\"lineColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#41937c\\\"}},\\\"lineWidth\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":0}},\\\"iconSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":6}},\\\"iconOrientation\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"orientation\\\":0}},\\\"labelText\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"\\\"}},\\\"labelColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#000000\\\"}},\\\"labelSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":14}},\\\"labelZoomRange\\\":{\\\"options\\\":{\\\"useLayerZoomRange\\\":true,\\\"minZoom\\\":0,\\\"maxZoom\\\":24}},\\\"labelBorderColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#FFFFFF\\\"}},\\\"symbolizeAs\\\":{\\\"options\\\":{\\\"value\\\":\\\"circle\\\"}},\\\"labelBorderSize\\\":{\\\"options\\\":{\\\"size\\\":\\\"SMALL\\\"}},\\\"labelPosition\\\":{\\\"options\\\":{\\\"position\\\":\\\"CENTER\\\"}}},\\\"isTimeAware\\\":true},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"MVT_VECTOR\\\",\\\"joins\\\":[],\\\"disableTooltips\\\":false}]\",\"mapStateJSON\":\"{\\\"adHocDataViews\\\":[],\\\"zoom\\\":3.53,\\\"center\\\":{\\\"lon\\\":-98.19524,\\\"lat\\\":42.06188},\\\"timeFilters\\\":{\\\"from\\\":\\\"2015-09-19T06:31:44.000Z\\\",\\\"to\\\":\\\"2015-09-23T18:31:44.000Z\\\"},\\\"refreshConfig\\\":{\\\"isPaused\\\":true,\\\"interval\\\":60000},\\\"query\\\":{\\\"query\\\":\\\"\\\",\\\"language\\\":\\\"kuery\\\"},\\\"filters\\\":[],\\\"settings\\\":{\\\"autoFitToDataBounds\\\":false,\\\"backgroundColor\\\":\\\"#ffffff\\\",\\\"customIcons\\\":[],\\\"disableInteractive\\\":false,\\\"disableTooltipControl\\\":false,\\\"hideToolbarOverlay\\\":false,\\\"hideLayerControl\\\":false,\\\"hideViewControl\\\":false,\\\"initialLocation\\\":\\\"LAST_SAVED_LOCATION\\\",\\\"fixedLocation\\\":{\\\"lat\\\":0,\\\"lon\\\":0,\\\"zoom\\\":2},\\\"browserLocation\\\":{\\\"zoom\\\":2},\\\"keydownScrollZoom\\\":false,\\\"maxZoom\\\":24,\\\"minZoom\\\":0,\\\"showScaleControl\\\":false,\\\"showSpatialFilters\\\":true,\\\"showTimesliderToggleButton\\\":true,\\\"spatialFiltersAlpa\\\":0.3,\\\"spatialFiltersFillColor\\\":\\\"#DA8B45\\\",\\\"spatialFiltersLineColor\\\":\\\"#DA8B45\\\"}}\",\"uiStateJSON\":\"{\\\"isLayerTOCOpen\\\":true,\\\"openTOCDetails\\\":[\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\"]}\"},\"mapCenter\":{\"lat\":42.37743,\"lon\":-101.55858,\"zoom\":3.53},\"mapBuffer\":{\"minLon\":-135,\"minLat\":21.94305,\"maxLon\":-45,\"maxLat\":55.77657},\"isLayerTOCOpen\":true,\"openTOCDetails\":[\"07facc2c-117d-4335-bd53-90e0ab36aa53\"],\"hiddenLayers\":[],\"enhancements\":{}}},{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":20,\"w\":10,\"h\":9,\"i\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"},\"panelIndex\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"timeRange\":{\"from\":\"2015-09-21T06:31:44.000Z\",\"to\":\"2015-09-23T18:31:44.000Z\"},\"enhancements\":{}},\"panelRefName\":\"panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"},{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":12,\"w\":10,\"h\":7,\"i\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\"},\"panelIndex\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\"},{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb\"}],\"state\":{\"visualization\":{\"layerId\":\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\",\"layerType\":\"data\",\"metricAccessor\":\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\",\"showBar\":false,\"trendlineLayerId\":\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\",\"trendlineLayerType\":\"metricTrendline\",\"trendlineTimeAccessor\":\"c993571d-2a82-4162-a367-3542041c811f\",\"trendlineMetricAccessor\":\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\",\"color\":\"#fccada\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\":{\"columns\":{\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}},\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\":{\"linkToLayers\":[\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\"],\"columns\":{\"c993571d-2a82-4162-a367-3542041c811f\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"c993571d-2a82-4162-a367-3542041c811f\",\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{}}}]","refreshInterval":{"pause":true,"value":60000},"timeFrom":"2015-09-19T06:31:44.000Z","timeRestore":true,"timeTo":"2015-09-23T18:31:44.000Z","title":"Super Saved Serverless","version":1},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T16:37:35.012Z","id":"4dc11f80-42b5-11ee-89b3-c776e03685a8","managed":false,"references":[{"id":"logstash-*","name":"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index","type":"index-pattern"},{"id":"logstash-*","name":"5b087cde-634a-4815-9093-71891a900380:indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","name":"ee9dceec-afc3-4258-9998-44a00c2b36fc:panel_ee9dceec-afc3-4258-9998-44a00c2b36fc","type":"lens"},{"id":"logstash-*","name":"7557df66-cfde-4401-a926-aff27d774715:layer_1_source_index_pattern","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","name":"d3089be5-dff0-4bbe-9a36-76dd1dec98ef:panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef","type":"lens"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f","type":"index-pattern"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb","type":"index-pattern"}],"type":"dashboard","typeMigrationVersion":"8.9.0","updated_at":"2023-08-25T16:37:35.012Z","version":"WzE2NCwxXQ=="} +{"attributes":{"fields":"[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]","timeFieldName":"@timestamp","title":"logstash-*"},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:19.398Z","id":"logstash-*","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"7.11.0","updated_at":"2023-09-28T17:07:19.398Z","version":"WzE4LDFd"} +{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"4ba1a1be-6e67-434b-b3a0-f30db8ea5395":{"columnOrder":["7a5d833b-ca6f-4e48-a924-d2a28d365dc3","3cf18f28-3495-4d45-a55f-d97f88022099","3dc0bd55-2087-4e60-aea2-f9910714f7db"],"columns":{"3cf18f28-3495-4d45-a55f-d97f88022099":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"includeEmptyRows":false,"interval":"auto"},"scale":"interval","sourceField":"@timestamp"},"3dc0bd55-2087-4e60-aea2-f9910714f7db":{"dataType":"number","isBucketed":false,"label":"Average of bytes","operationType":"average","scale":"ratio","sourceField":"bytes"},"7a5d833b-ca6f-4e48-a924-d2a28d365dc3":{"dataType":"ip","isBucketed":true,"label":"Top values of ip","operationType":"terms","params":{"orderBy":{"columnId":"3dc0bd55-2087-4e60-aea2-f9910714f7db","type":"column"},"orderDirection":"desc","size":3},"scale":"ordinal","sourceField":"ip"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"layers":[{"accessors":["3dc0bd55-2087-4e60-aea2-f9910714f7db"],"layerId":"4ba1a1be-6e67-434b-b3a0-f30db8ea5395","seriesType":"bar_stacked","splitAccessor":"7a5d833b-ca6f-4e48-a924-d2a28d365dc3","xAccessor":"3cf18f28-3495-4d45-a55f-d97f88022099"}],"legend":{"isVisible":true,"legendSize":"auto","position":"right"},"preferredSeriesType":"bar_stacked"}},"title":"lnsXYvis","visualizationType":"lnsXY"},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:19.398Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-4ba1a1be-6e67-434b-b3a0-f30db8ea5395","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-09-28T17:07:19.398Z","version":"WzE5LDFd"} +{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"c61a8afb-a185-4fae-a064-fb3846f6c451":{"columnOrder":["2cd09808-3915-49f4-b3b0-82767eba23f7"],"columns":{"2cd09808-3915-49f4-b3b0-82767eba23f7":{"dataType":"number","isBucketed":false,"label":"Maximum of bytes","operationType":"max","scale":"ratio","sourceField":"bytes"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"accessor":"2cd09808-3915-49f4-b3b0-82767eba23f7","isHorizontal":false,"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","layers":[{"accessors":["d3e62a7a-c259-4fff-a2fc-eebf20b7008a","26ef70a9-c837-444c-886e-6bd905ee7335"],"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","seriesType":"area","splitAccessor":"54cd64ed-2a44-4591-af84-b2624504569a","xAccessor":"d6e40cea-6299-43b4-9c9d-b4ee305a2ce8"}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"area","size":"xl","textAlign":"center","titlePosition":"bottom"}},"title":"Artistpreviouslyknownaslens","visualizationType":"lnsLegacyMetric"},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:19.398Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-c61a8afb-a185-4fae-a064-fb3846f6c451","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-09-28T17:07:19.398Z","version":"WzIwLDFd"} +{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":true,\"alias\":null,\"key\":\"agent.raw\",\"field\":\"agent.raw\",\"params\":{\"query\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"agent.raw\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"}},\"$state\":{\"store\":\"appState\"}}]}"},"optionsJSON":"{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}","panelsJSON":"[{\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":12,\"h\":13,\"i\":\"5b087cde-634a-4815-9093-71891a900380\"},\"panelIndex\":\"5b087cde-634a-4815-9093-71891a900380\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"shape\":\"donut\",\"layers\":[{\"layerId\":\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\",\"primaryGroups\":[\"fbf03774-001d-4032-a808-004140e94918\"],\"metrics\":[\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"default\",\"nestedLegend\":false,\"layerType\":\"data\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\":{\"columns\":{\"fbf03774-001d-4032-a808-004140e94918\":{\"label\":\"Top 3 values of ip\",\"dataType\":\"ip\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"ip\",\"isBucketed\":true,\"params\":{\"size\":3,\"orderBy\":{\"type\":\"column\",\"columnId\":\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false}},\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\":{\"label\":\"Average of bytes\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"bytes\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"fbf03774-001d-4032-a808-004140e94918\",\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{},\"hidePanelTitles\":false},\"title\":\"Custom Title\"},{\"type\":\"lens\",\"gridData\":{\"x\":12,\"y\":0,\"w\":36,\"h\":13,\"i\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\"},\"panelIndex\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"description\":\"Wow what a neat description\",\"enhancements\":{}},\"title\":\"Custom Title on a Library Item\",\"panelRefName\":\"panel_ee9dceec-afc3-4258-9998-44a00c2b36fc\"},{\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":13,\"w\":10,\"h\":7,\"i\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\"},\"panelIndex\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsMetric\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\",\"type\":\"index-pattern\"},{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"layerId\":\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\",\"layerType\":\"data\",\"metricAccessor\":\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\",\"showBar\":false,\"trendlineLayerId\":\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\",\"trendlineLayerType\":\"metricTrendline\",\"trendlineTimeAccessor\":\"c993571d-2a82-4162-a367-3542041c811f\",\"trendlineMetricAccessor\":\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\",\"color\":\"#fccada\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\":{\"columns\":{\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}},\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\":{\"linkToLayers\":[\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\"],\"columns\":{\"c993571d-2a82-4162-a367-3542041c811f\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"c993571d-2a82-4162-a367-3542041c811f\",\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{}}},{\"type\":\"map\",\"gridData\":{\"x\":0,\"y\":13,\"w\":38,\"h\":16,\"i\":\"7557df66-cfde-4401-a926-aff27d774715\"},\"panelIndex\":\"7557df66-cfde-4401-a926-aff27d774715\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"layerListJSON\":\"[{\\\"locale\\\":\\\"autoselect\\\",\\\"sourceDescriptor\\\":{\\\"type\\\":\\\"EMS_TMS\\\",\\\"isAutoSelect\\\":true,\\\"lightModeDefault\\\":\\\"road_map_desaturated\\\"},\\\"id\\\":\\\"710998eb-fda1-462c-80f2-d498df132ebf\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"EMS_VECTOR_TILE\\\",\\\"color\\\":\\\"\\\"},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"EMS_VECTOR_TILE\\\"},{\\\"sourceDescriptor\\\":{\\\"geoField\\\":\\\"geo.coordinates\\\",\\\"scalingType\\\":\\\"MVT\\\",\\\"id\\\":\\\"4ed5225d-d42d-40e3-9515-5a918208db27\\\",\\\"type\\\":\\\"ES_SEARCH\\\",\\\"applyGlobalQuery\\\":true,\\\"applyGlobalTime\\\":true,\\\"applyForceRefresh\\\":true,\\\"filterByMapBounds\\\":true,\\\"tooltipProperties\\\":[],\\\"sortField\\\":\\\"\\\",\\\"sortOrder\\\":\\\"desc\\\",\\\"topHitsGroupByTimeseries\\\":false,\\\"topHitsSplitField\\\":\\\"\\\",\\\"topHitsSize\\\":1,\\\"indexPatternRefName\\\":\\\"layer_1_source_index_pattern\\\"},\\\"id\\\":\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"VECTOR\\\",\\\"properties\\\":{\\\"icon\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"marker\\\"}},\\\"fillColor\\\":{\\\"type\\\":\\\"DYNAMIC\\\",\\\"options\\\":{\\\"color\\\":\\\"Blues\\\",\\\"colorCategory\\\":\\\"palette_0\\\",\\\"field\\\":{\\\"name\\\":\\\"extension.raw\\\",\\\"origin\\\":\\\"source\\\"},\\\"fieldMetaOptions\\\":{\\\"isEnabled\\\":true,\\\"sigma\\\":3},\\\"type\\\":\\\"CATEGORICAL\\\",\\\"otherCategoryColor\\\":\\\"#000000\\\"}},\\\"lineColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#41937c\\\"}},\\\"lineWidth\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":0}},\\\"iconSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":6}},\\\"iconOrientation\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"orientation\\\":0}},\\\"labelText\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"\\\"}},\\\"labelColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#000000\\\"}},\\\"labelSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":14}},\\\"labelZoomRange\\\":{\\\"options\\\":{\\\"useLayerZoomRange\\\":true,\\\"minZoom\\\":0,\\\"maxZoom\\\":24}},\\\"labelBorderColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#FFFFFF\\\"}},\\\"symbolizeAs\\\":{\\\"options\\\":{\\\"value\\\":\\\"circle\\\"}},\\\"labelBorderSize\\\":{\\\"options\\\":{\\\"size\\\":\\\"SMALL\\\"}},\\\"labelPosition\\\":{\\\"options\\\":{\\\"position\\\":\\\"CENTER\\\"}}},\\\"isTimeAware\\\":true},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"MVT_VECTOR\\\",\\\"joins\\\":[],\\\"disableTooltips\\\":false}]\",\"mapStateJSON\":\"{\\\"adHocDataViews\\\":[],\\\"zoom\\\":3.53,\\\"center\\\":{\\\"lon\\\":-98.19524,\\\"lat\\\":42.06188},\\\"timeFilters\\\":{\\\"from\\\":\\\"2015-09-19T06:31:44.000Z\\\",\\\"to\\\":\\\"2015-09-23T18:31:44.000Z\\\"},\\\"refreshConfig\\\":{\\\"isPaused\\\":true,\\\"interval\\\":60000},\\\"query\\\":{\\\"query\\\":\\\"\\\",\\\"language\\\":\\\"kuery\\\"},\\\"filters\\\":[],\\\"settings\\\":{\\\"autoFitToDataBounds\\\":false,\\\"backgroundColor\\\":\\\"#ffffff\\\",\\\"customIcons\\\":[],\\\"disableInteractive\\\":false,\\\"disableTooltipControl\\\":false,\\\"hideToolbarOverlay\\\":false,\\\"hideLayerControl\\\":false,\\\"hideViewControl\\\":false,\\\"initialLocation\\\":\\\"LAST_SAVED_LOCATION\\\",\\\"fixedLocation\\\":{\\\"lat\\\":0,\\\"lon\\\":0,\\\"zoom\\\":2},\\\"browserLocation\\\":{\\\"zoom\\\":2},\\\"keydownScrollZoom\\\":false,\\\"maxZoom\\\":24,\\\"minZoom\\\":0,\\\"showScaleControl\\\":false,\\\"showSpatialFilters\\\":true,\\\"showTimesliderToggleButton\\\":true,\\\"spatialFiltersAlpa\\\":0.3,\\\"spatialFiltersFillColor\\\":\\\"#DA8B45\\\",\\\"spatialFiltersLineColor\\\":\\\"#DA8B45\\\"}}\",\"uiStateJSON\":\"{\\\"isLayerTOCOpen\\\":true,\\\"openTOCDetails\\\":[\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\"]}\"},\"mapCenter\":{\"lat\":42.37743,\"lon\":-101.55858,\"zoom\":3.53},\"mapBuffer\":{\"minLon\":-135,\"minLat\":21.94305,\"maxLon\":-45,\"maxLat\":55.77657},\"isLayerTOCOpen\":true,\"openTOCDetails\":[\"07facc2c-117d-4335-bd53-90e0ab36aa53\"],\"hiddenLayers\":[],\"enhancements\":{}}},{\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":20,\"w\":10,\"h\":9,\"i\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"},\"panelIndex\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"timeRange\":{\"from\":\"2015-09-21T06:31:44.000Z\",\"to\":\"2015-09-23T18:31:44.000Z\"},\"enhancements\":{}},\"panelRefName\":\"panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"}]","refreshInterval":{"pause":true,"value":60000},"timeFrom":"2015-09-19T06:31:44.000Z","timeRestore":true,"timeTo":"2015-09-23T18:31:44.000Z","title":"Super Saved Serverless","version":1},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:48.232Z","id":"4dc11f80-42b5-11ee-89b3-c776e03685a8","managed":false,"references":[{"id":"logstash-*","name":"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index","type":"index-pattern"},{"id":"logstash-*","name":"5b087cde-634a-4815-9093-71891a900380:indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","name":"ee9dceec-afc3-4258-9998-44a00c2b36fc:panel_ee9dceec-afc3-4258-9998-44a00c2b36fc","type":"lens"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f","type":"index-pattern"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb","type":"index-pattern"},{"id":"logstash-*","name":"7557df66-cfde-4401-a926-aff27d774715:layer_1_source_index_pattern","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","name":"d3089be5-dff0-4bbe-9a36-76dd1dec98ef:panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef","type":"lens"}],"type":"dashboard","typeMigrationVersion":"8.9.0","updated_at":"2023-09-28T17:07:48.232Z","version":"WzI0LDFd"} {"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":4,"missingRefCount":0,"missingReferences":[]} \ No newline at end of file From b1ca381f4b9f2d86c796f609068bad3c5f9cb7cf Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 28 Sep 2023 11:19:58 -0600 Subject: [PATCH 42/53] Fix links hash --- .../migrations/group2/check_registered_types.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index ac1f453de2b9e..f036c94659d26 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -113,7 +113,7 @@ describe('checking migration metadata changes on all registered SO types', () => "legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8", "lens": "5cfa2c52b979b4f8df56dd13c477e152183468b9", "lens-ui-telemetry": "8c47a9e393861f76e268345ecbadfc8a5fb1e0bd", - "links": "a2c467f8b00e851d343dace3ef532d7fb29462e7", + "links": "39117a08966e9082d0f47b0b2e7e508499fc1e6d", "maintenance-window": "d893544460abad56ff7a0e25b78f78776dfe10d1", "map": "76c71023bd198fb6b1163b31bafd926fe2ceb9da", "metrics-data-source": "81b69dc9830699d9ead5ac8dcb9264612e2a3c89", From 79eb54df3527a511eb0d1e58e73d170811c5822e Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 28 Sep 2023 14:14:52 -0400 Subject: [PATCH 43/53] Soften response validation --- .../server/content_management/links_storage.ts | 11 ++++++++++- src/plugins/links/server/index.ts | 3 ++- src/plugins/links/server/plugin.ts | 13 +++++++++++-- src/plugins/links/tsconfig.json | 4 +++- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/plugins/links/server/content_management/links_storage.ts b/src/plugins/links/server/content_management/links_storage.ts index b5f5180330164..21a5e4aa0de0d 100644 --- a/src/plugins/links/server/content_management/links_storage.ts +++ b/src/plugins/links/server/content_management/links_storage.ts @@ -6,18 +6,27 @@ * Side Public License, v 1. */ +import type { Logger } from '@kbn/logging'; import { SOContentStorage } from '@kbn/content-management-utils'; import { CONTENT_ID } from '../../common'; import type { LinksCrudTypes } from '../../common/content_management'; import { cmServicesDefinition } from '../../common/content_management/cm_services'; export class LinksStorage extends SOContentStorage { - constructor() { + constructor({ + logger, + throwOnResultValidationError, + }: { + logger: Logger; + throwOnResultValidationError: boolean; + }) { super({ savedObjectType: CONTENT_ID, cmServicesDefinition, enableMSearch: true, allowedSavedObjectAttributes: ['id', 'title', 'description', 'links', 'layout'], + logger, + throwOnResultValidationError, }); } } diff --git a/src/plugins/links/server/index.ts b/src/plugins/links/server/index.ts index 781ca3c1274ab..c60d084fa66d2 100644 --- a/src/plugins/links/server/index.ts +++ b/src/plugins/links/server/index.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { PluginInitializerContext } from '@kbn/core-plugins-server'; import { LinksServerPlugin } from './plugin'; -export const plugin = () => new LinksServerPlugin(); +export const plugin = (initContext: PluginInitializerContext) => new LinksServerPlugin(initContext); diff --git a/src/plugins/links/server/plugin.ts b/src/plugins/links/server/plugin.ts index de40f9f4b36cc..b1a0bfafed763 100644 --- a/src/plugins/links/server/plugin.ts +++ b/src/plugins/links/server/plugin.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server'; import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; import { CONTENT_ID, LATEST_VERSION } from '../common'; import { LinksAttributes } from '../common/content_management'; @@ -14,6 +14,12 @@ import { LinksStorage } from './content_management'; import { linksSavedObjectType } from './saved_objects'; export class LinksServerPlugin implements Plugin { + private readonly logger: Logger; + + constructor(private initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + public setup( core: CoreSetup, plugins: { @@ -22,7 +28,10 @@ export class LinksServerPlugin implements Plugin { ) { plugins.contentManagement.register({ id: CONTENT_ID, - storage: new LinksStorage(), + storage: new LinksStorage({ + throwOnResultValidationError: this.initializerContext.env.mode.dev, + logger: this.logger.get('storage'), + }), version: { latest: LATEST_VERSION, }, diff --git a/src/plugins/links/tsconfig.json b/src/plugins/links/tsconfig.json index 8a0b7e037928c..28a2f259183d0 100644 --- a/src/plugins/links/tsconfig.json +++ b/src/plugins/links/tsconfig.json @@ -25,7 +25,9 @@ "@kbn/share-plugin", "@kbn/kibana-utils-plugin", "@kbn/utility-types", - "@kbn/ui-actions-plugin" + "@kbn/ui-actions-plugin", + "@kbn/logging", + "@kbn/core-plugins-server" ], "exclude": ["target/**/*"] } From e0b2c388cee54d895e6e655271439b49faf333d2 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 28 Sep 2023 14:20:43 -0400 Subject: [PATCH 44/53] [Dashboard navigation] Functional tests for links panel (#167324) ## Summary Adds functional tests to the Links panel feature branch. Flaky test runner forthcoming... --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .buildkite/ftr_configs.yml | 1 + .../kbn_client/kbn_client_saved_objects.ts | 1 + .../dashboard_link_component.tsx | 14 +- .../dashboard_link_destination_picker.tsx | 1 + .../components/editor/link_destination.tsx | 5 +- .../public/components/editor/links_editor.tsx | 4 + .../editor/links_editor_empty_prompt.tsx | 7 +- .../components/editor/links_editor_link.tsx | 11 +- .../components/editor/panel_editor_link.tsx | 3 + .../external_link/external_link_component.tsx | 8 +- .../external_link_destination_picker.tsx | 1 + .../public/components/links_component.tsx | 7 +- .../public/editor/open_editor_flyout.tsx | 1 + .../dashboard_drilldown_options.tsx | 3 + .../url_drilldown_options.tsx | 1 + .../apps/dashboard_elements/links/config.ts | 38 ++ .../apps/dashboard_elements/links/index.ts | 45 +++ .../links/links_create_edit.ts | 152 ++++++++ .../links/links_navigation.ts | 222 ++++++++++++ .../dashboard/current/kibana.json | 334 ++++++++++++++++-- .../functional/page_objects/dashboard_page.ts | 6 +- .../page_objects/dashboard_page_links.ts | 193 ++++++++++ test/functional/page_objects/index.ts | 2 + test/tsconfig.json | 3 +- 24 files changed, 1013 insertions(+), 50 deletions(-) create mode 100644 test/functional/apps/dashboard_elements/links/config.ts create mode 100644 test/functional/apps/dashboard_elements/links/index.ts create mode 100644 test/functional/apps/dashboard_elements/links/links_create_edit.ts create mode 100644 test/functional/apps/dashboard_elements/links/links_navigation.ts create mode 100644 test/functional/page_objects/dashboard_page_links.ts diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index b25f6f1bf44ac..1c026f571db60 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -100,6 +100,7 @@ enabled: - test/functional/apps/dashboard_elements/controls/options_list/config.ts - test/functional/apps/dashboard_elements/image_embeddable/config.ts - test/functional/apps/dashboard_elements/input_control_vis/config.ts + - test/functional/apps/dashboard_elements/links/config.ts - test/functional/apps/dashboard_elements/markdown/config.ts - test/functional/apps/dashboard/group1/config.ts - test/functional/apps/dashboard/group2/config.ts diff --git a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts index c64c9d7a543aa..63e60a0eb19e4 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts @@ -96,6 +96,7 @@ const STANDARD_LIST_TYPES = [ 'dashboard', 'search', 'lens', + 'links', 'map', 'cases', // synthetics based objects diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx index f81469bb34527..563cf6277c796 100644 --- a/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx @@ -133,13 +133,11 @@ export const DashboardLinkComponent = ({ }; }, [link]); + const id = `dashboardLink--${link.id}`; + return loadingDestinationDashboard ? ( -

  • + {DashboardLinkStrings.getLoadingDashboardLabel()}
  • @@ -156,7 +154,7 @@ export const DashboardLinkComponent = ({ position: layout === LINKS_VERTICAL_LAYOUT ? 'right' : 'bottom', repositionOnScroll: true, delay: 'long', - 'data-test-subj': `dashboardLink--${link.id}--tooltip`, + 'data-test-subj': `${id}--tooltip`, }} iconType={error ? 'warning' : undefined} iconProps={{ className: 'dashboardLinkIcon' }} @@ -167,7 +165,7 @@ export const DashboardLinkComponent = ({ 'dashboardLinkError--noLabel': !link.label, })} label={linkLabel} - data-test-subj={error ? `dashboardLink--${link.id}--error` : `dashboardLink--${link.id}`} + data-test-subj={error ? `${id}--error` : `${id}`} /> ); }; diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx index 626a85fc5b16d..137d604c2e01e 100644 --- a/src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx @@ -143,6 +143,7 @@ export const DashboardLinkDestinationPicker = ({ onDestinationPicked(undefined); } }} + data-test-subj="links--linkEditor--dashboardLink--comboBox" /> ); }; diff --git a/src/plugins/links/public/components/editor/link_destination.tsx b/src/plugins/links/public/components/editor/link_destination.tsx index ff6b89606c438..bd33b6245ab51 100644 --- a/src/plugins/links/public/components/editor/link_destination.tsx +++ b/src/plugins/links/public/components/editor/link_destination.tsx @@ -45,11 +45,14 @@ export const LinkDestination = ({ link && link.type === EXTERNAL_LINK_TYPE ? link.destination : undefined ); + const isInvalid = Boolean(destinationError); + return ( {selectedLinkType === DASHBOARD_LINK_TYPE ? ( {orderedLinks.map((link, idx) => ( addOrEditLink()} + data-test-subj="links--panelEditor--addLinkBtn" > {LinksStrings.editor.getAddButtonLabel()} diff --git a/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx b/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx index ffb4527e1968f..763b08437e4d3 100644 --- a/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx +++ b/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx @@ -24,7 +24,12 @@ export const LinksEditorEmptyPrompt = ({ addLink }: { addLink: () => Promise {LinksStrings.editor.panelEditor.getEmptyLinksMessage()} - + {LinksStrings.editor.getAddButtonLabel()} diff --git a/src/plugins/links/public/components/editor/links_editor_link.tsx b/src/plugins/links/public/components/editor/links_editor_link.tsx index 43af210f6aacc..0d20bf6925acc 100644 --- a/src/plugins/links/public/components/editor/links_editor_link.tsx +++ b/src/plugins/links/public/components/editor/links_editor_link.tsx @@ -72,6 +72,7 @@ export const LinkEditor = ({ {LinkInfo[type].displayName} ), + 'data-test-subj': `links--linkEditor--${type}--radioBtn`, }; }); }, []); @@ -89,7 +90,7 @@ export const LinkEditor = ({ ); return ( - + setCurrentLinkLabel(e.target.value)} + data-test-subj="links--linkEditor--linkLabel--input" /> - onClose()} iconType="cross"> + onClose()} + iconType="cross" + data-test-subj="links--linkEditor--closeBtn" + > {LinksStrings.editor.getCancelButtonLabel()} @@ -168,6 +174,7 @@ export const LinkEditor = ({ onClose(); } }} + data-test-subj="links--linkEditor--saveBtn" > {link ? LinksStrings.editor.getUpdateButtonLabel() diff --git a/src/plugins/links/public/components/editor/panel_editor_link.tsx b/src/plugins/links/public/components/editor/panel_editor_link.tsx index f363e17ec0da4..92af155d11753 100644 --- a/src/plugins/links/public/components/editor/panel_editor_link.tsx +++ b/src/plugins/links/public/components/editor/panel_editor_link.tsx @@ -143,6 +143,7 @@ export const PanelEditorLink = ({ paddingSize="none" {...dragHandleProps} aria-label={LinksStrings.editor.panelEditor.getDragHandleAriaLabel()} + data-test-subj="panelEditorLink--dragHandle" >
    @@ -160,6 +161,7 @@ export const PanelEditorLink = ({ iconType="pencil" onClick={editLink} aria-label={LinksStrings.editor.getEditLinkTitle()} + data-test-subj="panelEditorLink--editBtn" /> @@ -171,6 +173,7 @@ export const PanelEditorLink = ({ aria-label={LinksStrings.editor.getDeleteLinkTitle()} color="danger" onClick={deleteLink} + data-test-subj="panelEditorLink--deleteBtn" /> diff --git a/src/plugins/links/public/components/external_link/external_link_component.tsx b/src/plugins/links/public/components/external_link/external_link_component.tsx index c2fee4194c6a8..9c2231fe6b711 100644 --- a/src/plugins/links/public/components/external_link/external_link_component.tsx +++ b/src/plugins/links/public/components/external_link/external_link_component.tsx @@ -47,6 +47,8 @@ export const ExternalLinkComponent = ({ : link.destination; }, [linkOptions, link.destination]); + const id = `externalLink--${link.id}`; + return ( { if (!destination) return; diff --git a/src/plugins/links/public/components/external_link/external_link_destination_picker.tsx b/src/plugins/links/public/components/external_link/external_link_destination_picker.tsx index a509787d3f390..30aca970a0783 100644 --- a/src/plugins/links/public/components/external_link/external_link_destination_picker.tsx +++ b/src/plugins/links/public/components/external_link/external_link_destination_picker.tsx @@ -78,6 +78,7 @@ export const ExternalLinkDestinationPicker = ({ setDestinationError(message); } }} + data-test-subj="links--linkEditor--externalLink--input" />
    ); diff --git a/src/plugins/links/public/components/links_component.tsx b/src/plugins/links/public/components/links_component.tsx index c98fce1fcdaf9..9400dc9fe7308 100644 --- a/src/plugins/links/public/components/links_component.tsx +++ b/src/plugins/links/public/components/links_component.tsx @@ -61,8 +61,13 @@ export const LinksComponent = () => { layout === LINKS_HORIZONTAL_LAYOUT ? 'eui-xScroll' : 'eui-yScroll' }`} paddingSize="xs" + data-test-subj="links--component" > - + {orderedLinks.map((link) => linkItems[link.id].content)}
    diff --git a/src/plugins/links/public/editor/open_editor_flyout.tsx b/src/plugins/links/public/editor/open_editor_flyout.tsx index 6c8f74ea5934a..b6d03b703c74a 100644 --- a/src/plugins/links/public/editor/open_editor_flyout.tsx +++ b/src/plugins/links/public/editor/open_editor_flyout.tsx @@ -118,6 +118,7 @@ export async function openEditorFlyout( outsideClickCloses: false, onClose: onCancel, className: 'linksPanelEditor', + 'data-test-subj': 'links--panelEditor--flyout', } ); diff --git a/src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx b/src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx index d6df3ac4af240..a87195b305f1b 100644 --- a/src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx +++ b/src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx @@ -29,6 +29,7 @@ export const DashboardDrilldownOptionsComponent = ({ label={dashboardDrilldownConfigStrings.component.getUseCurrentFiltersLabel()} checked={options.useCurrentFilters} onChange={() => onOptionChange({ useCurrentFilters: !options.useCurrentFilters })} + data-test-subj="dashboardDrillDownOptions--useCurrentFilters--checkbox" /> @@ -37,6 +38,7 @@ export const DashboardDrilldownOptionsComponent = ({ label={dashboardDrilldownConfigStrings.component.getUseCurrentDateRange()} checked={options.useCurrentDateRange} onChange={() => onOptionChange({ useCurrentDateRange: !options.useCurrentDateRange })} + data-test-subj="dashboardDrillDownOptions--useCurrentDateRange--checkbox" /> @@ -45,6 +47,7 @@ export const DashboardDrilldownOptionsComponent = ({ label={dashboardDrilldownConfigStrings.component.getOpenInNewTab()} checked={options.openInNewTab} onChange={() => onOptionChange({ openInNewTab: !options.openInNewTab })} + data-test-subj="dashboardDrillDownOptions--openInNewTab--checkbox" /> diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx index d8846556e94c4..a0f5da726a13a 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx @@ -50,6 +50,7 @@ export const UrlDrilldownOptionsComponent = ({ } checked={options.encodeUrl} onChange={() => onOptionChange({ encodeUrl: !options.encodeUrl })} + data-test-subj="urlDrilldownEncodeUrl" /> diff --git a/test/functional/apps/dashboard_elements/links/config.ts b/test/functional/apps/dashboard_elements/links/config.ts new file mode 100644 index 0000000000000..f6692ef0d0772 --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/config.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const commonConfig = await readConfigFile(require.resolve('../../../../common/config.js')); + const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js')); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('.')], + junit: { + reportName: 'Dashboard Elements - Links panel tests', + }, + kbnTestServer: { + ...commonConfig.get('kbnTestServer'), + serverArgs: [ + ...commonConfig.get('kbnTestServer.serverArgs'), + `--externalUrl.policy=${JSON.stringify([ + { + allow: false, + host: 'danger.example.com', + }, + { + allow: true, + host: 'example.com', + }, + ])}`, + ], + }, + }; +} diff --git a/test/functional/apps/dashboard_elements/links/index.ts b/test/functional/apps/dashboard_elements/links/index.ts new file mode 100644 index 0000000000000..6c2b1372f07e1 --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/index.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile, getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const security = getService('security'); + + const { dashboard } = getPageObjects(['dashboardControls', 'dashboard']); + + async function setup() { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + + await dashboard.navigateToApp(); + await dashboard.preserveCrossAppState(); + } + + async function teardown() { + await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); + await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); + } + + describe('links panel', function () { + before(setup); + after(teardown); + loadTestFile(require.resolve('./links_create_edit')); + loadTestFile(require.resolve('./links_navigation')); + }); +} diff --git a/test/functional/apps/dashboard_elements/links/links_create_edit.ts b/test/functional/apps/dashboard_elements/links/links_create_edit.ts new file mode 100644 index 0000000000000..a5d4c8733c195 --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/links_create_edit.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const deployment = getService('deployment'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const { dashboardLinks, dashboard, common, header } = getPageObjects([ + 'dashboardLinks', + 'dashboard', + 'common', + 'header', + ]); + + async function createSomeLinks() { + await dashboardLinks.addExternalLink( + `${deployment.getHostPort()}/app/foo`, + true, + true, + 'Link to new tab' + ); + await dashboardLinks.addExternalLink(`${deployment.getHostPort()}/app/bar`, false, true); + + await dashboardLinks.addDashboardLink(DASHBOARD_NAME); + await dashboardLinks.addDashboardLink('links 001'); + } + + const DASHBOARD_NAME = 'Test Links panel'; + const LINKS_PANEL_NAME = 'Some links'; + + describe('links panel', () => { + describe('creation', async () => { + before(async () => { + await dashboard.navigateToApp(); + await dashboard.preserveCrossAppState(); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await dashboard.saveDashboard(DASHBOARD_NAME, { exitFromEditMode: false }); + await dashboard.loadSavedDashboard(DASHBOARD_NAME); + await dashboard.switchToEditMode(); + }); + + it('can not add an external link that violates externalLinks.policy', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + + await dashboardLinks.setExternalUrlInput('https://danger.example.com'); + expect(await testSubjects.exists('links--linkDestination--error')).to.be(true); + await dashboardLinks.clickLinkEditorCloseButton(); + await dashboardLinks.clickPanelEditorCloseButton(); + }); + + it('can create a new by-reference links panel', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + + await createSomeLinks(); + await dashboardLinks.toggleSaveByReference(true); + await dashboardLinks.clickPanelEditorSaveButton(); + + await testSubjects.exists('savedObjectSaveModal'); + await testSubjects.setValue('savedObjectTitle', LINKS_PANEL_NAME); + await testSubjects.click('confirmSaveSavedObjectButton'); + await common.waitForSaveModalToClose(); + await testSubjects.exists('addObjectToDashboardSuccess'); + + expect(await testSubjects.existOrFail('links--component')); + expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(4); + await dashboard.clickDiscardChanges(); + }); + + it('can create a new by-value links panel', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + await dashboardLinks.setLayout('horizontal'); + await createSomeLinks(); + await dashboardLinks.toggleSaveByReference(false); + await dashboardLinks.clickPanelEditorSaveButton(); + await testSubjects.exists('addObjectToDashboardSuccess'); + + expect(await testSubjects.existOrFail('links--component')); + expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(4); + await dashboard.clickDiscardChanges(); + }); + }); + + describe('editing', () => { + it('can reorder links in an existing panel', async () => { + await dashboard.loadSavedDashboard('links 001'); + await dashboard.switchToEditMode(); + + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await dashboardLinks.expectFlyoutIsOpen(); + + // Move the third link up one step + dashboardLinks.reorderLinks('link003', 3, 1, true); + + await dashboardLinks.clickPanelEditorSaveButton(); + await header.waitUntilLoadingHasFinished(); + + // The second link in the component should be the link we moved + const listGroup = await testSubjects.find('links--component--listGroup'); + const listItem = await listGroup.findByCssSelector(`li:nth-child(2)`); + expect(await listItem.getVisibleText()).to.equal('links 003 - external'); + }); + + it('can edit link in existing panel', async () => { + await dashboard.loadSavedDashboard('links 001'); + await dashboard.switchToEditMode(); + + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await dashboardLinks.expectFlyoutIsOpen(); + + await dashboardLinks.editLinkByIndex(5); + await testSubjects.exists('links--linkEditor--flyout'); + await testSubjects.setValue('links--linkEditor--linkLabel--input', 'to be deleted'); + await dashboardLinks.clickLinksEditorSaveButton(); + await dashboardLinks.clickPanelEditorSaveButton(); + + await header.waitUntilLoadingHasFinished(); + const link = await testSubjects.find('dashboardLink--link005'); + expect(await link.getVisibleText()).to.equal('to be deleted'); + }); + + it('can delete link from existing panel', async () => { + await dashboard.loadSavedDashboard('links 001'); + await dashboard.switchToEditMode(); + + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await dashboardLinks.expectFlyoutIsOpen(); + + await dashboardLinks.deleteLinkByIndex(5); + await dashboardLinks.clickPanelEditorSaveButton(); + + await header.waitUntilLoadingHasFinished(); + expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(5); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/links/links_navigation.ts b/test/functional/apps/dashboard_elements/links/links_navigation.ts new file mode 100644 index 0000000000000..122084894bfdc --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/links_navigation.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const filterBar = getService('filterBar'); + const testSubjects = getService('testSubjects'); + const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); + + const { dashboard, common, timePicker } = getPageObjects(['dashboard', 'common', 'timePicker']); + + const FROM_TIME = 'Oct 22, 2018 @ 00:00:00.000'; + const TO_TIME = 'Dec 3, 2018 @ 00:00:00.000'; + + describe('links panel', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await security.testUser.setRoles([ + 'kibana_admin', + 'kibana_sample_admin', + 'test_logstash_reader', + ]); + await esArchiver.load('test/functional/fixtures/es_archiver/kibana_sample_data_flights'); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern' + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await common.setTime({ + from: FROM_TIME, + to: TO_TIME, + }); + }); + + after(async () => { + await kibanaServer.importExport.unload( + 'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern' + ); + await esArchiver.unload('test/functional/fixtures/es_archiver/kibana_sample_data_flights'); + await kibanaServer.uiSettings.unset('defaultIndex'); + await common.unsetTime(); + await security.testUser.restoreDefaults(); + }); + + describe('embeddable panel', () => { + afterEach(async () => { + await dashboard.clickDiscardChanges(); + }); + + it('adds links panel to top of dashboard', async () => { + await dashboard.loadSavedDashboard('links 003'); + await dashboard.switchToEditMode(); + await dashboardAddPanel.addEmbeddable('a few horizontal links', 'links'); + const topPanelTitle = (await dashboard.getPanelTitles())[0]; + expect(topPanelTitle).to.equal('a few horizontal links'); + }); + }); + + describe('dashboard links', () => { + afterEach(async () => { + // close any new tabs that were opened + const windowHandlers = await browser.getAllWindowHandles(); + if (windowHandlers.length > 1) { + await browser.closeCurrentWindow(); + await browser.switchToWindow(windowHandlers[0]); + } + }); + + it('should disable link if dashboard does not exist', async () => { + await dashboard.loadSavedDashboard('links 001'); + expect(await testSubjects.exists('dashboardLink--link004--error')).to.be(true); + expect(await testSubjects.isEnabled('dashboardLink--link004--error')).to.be(false); + }); + + it('useCurrentFilters should pass filter pills and query', async () => { + /** + * dashboard links002 has a saved filter and query bar. + * The link to dashboard links001 only has useCurrentFilters enabled + * so the link should pass the filters and query to dashboard links001 + * but should not override the date range. + */ + await dashboard.loadSavedDashboard('links 002'); + await testSubjects.click('dashboardLink--link001'); + expect(await dashboard.getDashboardIdFromCurrentUrl()).to.equal( + '0930f310-5bc2-11ee-9a85-7b86504227bc' + ); + // Should pass the filters + expect(await filterBar.getFilterCount()).to.equal(2); + const filterLabels = await filterBar.getFiltersLabel(); + expect( + filterLabels.includes('This filter should only pass from links002 to links001') + ).to.equal(true); + expect( + filterLabels.includes('This filter should not pass from links001 to links002') + ).to.equal(true); + + // Should not pass the date range + const time = await timePicker.getTimeConfig(); + expect(time.start).to.be('Oct 31, 2018 @ 00:00:00.000'); + expect(time.end).to.be('Nov 1, 2018 @ 00:00:00.000'); + + await dashboard.clickDiscardChanges(); + }); + + it('useCurrentDateRange should pass date range', async () => { + /** + * dashboard links001 has saved filters and a saved date range. + * dashboard links002 has a different saved date range than links001. + * The link to dashboard links002 only has useCurrentDateRange enabled + * so the link should override the date range on dashboard links002 + * but should not pass its filters. + */ + await dashboard.loadSavedDashboard('links 001'); + await testSubjects.click('dashboardLink--link002'); + expect(await dashboard.getDashboardIdFromCurrentUrl()).to.equal( + '24751520-5bc2-11ee-9a85-7b86504227bc' + ); + // Should pass the date range + const time = await timePicker.getTimeConfig(); + expect(time.start).to.be('Oct 31, 2018 @ 00:00:00.000'); + expect(time.end).to.be('Nov 1, 2018 @ 00:00:00.000'); + + // Should not pass the filters + expect(await filterBar.getFilterCount()).to.equal(1); + const filterLabels = await filterBar.getFiltersLabel(); + expect( + filterLabels.includes('This filter should only pass from links002 to links001') + ).to.equal(true); + expect( + filterLabels.includes('This filter should not pass from links001 to links002') + ).to.equal(false); + + await dashboard.clickDiscardChanges(); + }); + + it('openInNewTab should create an external link', async () => { + /** + * The link to dashboard links003 only has openInNewTab enabled. + * Clicking the link should open a new tab. + * Other dashboards should not pass their filters or date range + * to dashboard links003. + */ + await dashboard.loadSavedDashboard('links 001'); + await testSubjects.click('dashboardLink--link003'); + + // Should have opened another tab + const windowHandlers = await browser.getAllWindowHandles(); + expect(windowHandlers.length).to.equal(2); + await browser.switchToWindow(windowHandlers[1]); + expect(await dashboard.getDashboardIdFromCurrentUrl()).to.equal( + '27398c50-5bc2-11ee-9a85-7b86504227bc' + ); + + // Should not pass any filters + expect((await filterBar.getFiltersLabel()).length).to.equal(0); + + // Should not pass any date range + const time = await timePicker.getTimeConfig(); + expect(time.start).to.be('Dec 24, 2018 @ 00:00:00.000'); + expect(time.end).to.be('Dec 26, 2018 @ 00:00:00.000'); + }); + }); + + describe('external links', () => { + before(async () => { + await dashboard.loadSavedDashboard('dashboard with external links'); + }); + + afterEach(async () => { + // close any new tabs that were opened + const windowHandlers = await browser.getAllWindowHandles(); + if (windowHandlers.length > 1) { + await browser.closeCurrentWindow(); + await browser.switchToWindow(windowHandlers[0]); + } + }); + + it('should disable link if forbidden by external url policy', async () => { + const button = await testSubjects.find('externalLink--link777--error'); + const isDisabled = await button.getAttribute('disabled'); + expect(isDisabled).to.be('true'); + }); + + it('should create an external link when openInNewTab is enabled', async () => { + await testSubjects.click('externalLink--link999'); + + // Should have opened another tab + const windowHandlers = await browser.getAllWindowHandles(); + expect(windowHandlers.length).to.equal(2); + await browser.switchToWindow(windowHandlers[1]); + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.be('https://example.com/1'); + }); + + it('should open in same tab when openInNewTab is disabled', async () => { + await testSubjects.click('externalLink--link888'); + + // Should have opened in the same tab + const windowHandlers = await browser.getAllWindowHandles(); + expect(windowHandlers.length).to.equal(1); + await browser.switchToWindow(windowHandlers[0]); + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.be('https://example.com/2'); + }); + }); + }); +} diff --git a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json index 6b82afb60d841..500443f11900a 100644 --- a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json +++ b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json @@ -2751,49 +2751,174 @@ "namespaces": [ "default" ], - "updated_at": "2023-09-25T16:40:49.785Z", - "created_at": "2023-09-25T16:39:26.070Z", - "version": "WzEyMzgsMV0=", + "updated_at": "2023-09-26T17:43:44.844Z", + "created_at": "2023-09-26T16:30:45.296Z", + "version": "WzIxMDEsMV0=", "attributes": { "links": [ { + "label": "links 001 - filters", "type": "dashboardLink", - "id": "link01", + "id": "link001", + "options": { + "openInNewTab": false, + "useCurrentDateRange": false, + "useCurrentFilters": true + }, "order": 0, - "destinationRefName": "link_link01_dashboard" + "destinationRefName": "link_link001_dashboard" }, { + "label": "links 002 - date range", "type": "dashboardLink", - "id": "link02", + "id": "link002", + "options": { + "openInNewTab": false, + "useCurrentDateRange": true, + "useCurrentFilters": false + }, "order": 1, - "destinationRefName": "link_link02_dashboard" + "destinationRefName": "link_link002_dashboard" }, { + "label": "links 003 - external", "type": "dashboardLink", - "id": "link03", + "id": "link003", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, "order": 2, - "destinationRefName": "link_link03_dashboard" + "destinationRefName": "link_link003_dashboard" + } + ], + "layout": "vertical", + "title": "a few vertical links", + "description": "" + }, + "references": [ + { + "name": "link_link001_dashboard", + "type": "dashboard", + "id": "0930f310-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link002_dashboard", + "type": "dashboard", + "id": "24751520-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link003_dashboard", + "type": "dashboard", + "id": "27398c50-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0" +} + +{ + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c", + "type": "links", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-26T17:43:44.844Z", + "created_at": "2023-09-26T16:30:45.296Z", + "version": "WzIxMDEsMV0=", + "attributes": { + "links": [ + { + "label": "links 001 - filters", + "type": "dashboardLink", + "id": "link001", + "options": { + "openInNewTab": false, + "useCurrentDateRange": false, + "useCurrentFilters": true + }, + "order": 0, + "destinationRefName": "link_link001_dashboard" + }, + { + "label": "links 002 - date range", + "type": "dashboardLink", + "id": "link002", + "options": { + "openInNewTab": false, + "useCurrentDateRange": true, + "useCurrentFilters": false + }, + "order": 1, + "destinationRefName": "link_link002_dashboard" + }, + { + "label": "links 003 - external", + "type": "dashboardLink", + "id": "link003", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, + "order": 2, + "destinationRefName": "link_link003_dashboard" + }, + { + "label": "links 004 - broken", + "type": "dashboardLink", + "id": "link004", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, + "order": 3, + "destinationRefName": "link_link004_dashboard" + }, + { + "label": "links 005 - delete me", + "type": "dashboardLink", + "id": "link005", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, + "order": 4, + "destinationRefName": "link_link005_dashboard" } ], "layout": "horizontal", - "title": "a few links", + "title": "a few horizontal links", "description": "" }, "references": [ { - "name": "link_link01_dashboard", + "name": "link_link001_dashboard", "type": "dashboard", "id": "0930f310-5bc2-11ee-9a85-7b86504227bc" }, { - "name": "link_link02_dashboard", + "name": "link_link002_dashboard", "type": "dashboard", "id": "24751520-5bc2-11ee-9a85-7b86504227bc" }, { - "name": "link_link03_dashboard", + "name": "link_link003_dashboard", "type": "dashboard", "id": "27398c50-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link004_dashboard", + "type": "dashboard", + "id": "does-not-exist" + }, + { + "name": "link_link005_dashboard", + "type": "dashboard", + "id": "89566e10-5d4a-11ee-9513-d3f0b68b64f8" } ], "managed": false, @@ -2806,25 +2931,36 @@ "namespaces": [ "default" ], - "updated_at": "2023-09-25T16:39:42.362Z", - "created_at": "2023-09-25T16:39:42.362Z", - "version": "WzEyMzMsMV0=", + "updated_at": "2023-09-26T17:51:54.615Z", + "created_at": "2023-09-26T17:51:54.615Z", + "version": "WzIxMDksMV0=", "attributes": { "version": 1, "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":false,\"alias\":\"This filter should not pass from links001 to links002\",\"key\":\"geo.dest\",\"field\":\"geo.dest\",\"params\":{\"query\":\"CA\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"geo.dest\":\"CA\"}},\"$state\":{\"store\":\"appState\"}}]}" }, "description": "", - "timeRestore": false, + "refreshInterval": { + "pause": true, + "value": 60000 + }, + "timeRestore": true, "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":4,\"i\":\"26533acc-3516-445c-9c05-3b0e18686f38\"},\"panelIndex\":\"26533acc-3516-445c-9c05-3b0e18686f38\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_26533acc-3516-445c-9c05-3b0e18686f38\"}]", - "title": "links 001" + "timeFrom": "2018-10-31T00:00:00.000Z", + "title": "links 001", + "timeTo": "2018-11-01T00:00:00.000Z" }, "references": [ + { + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern", + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c" + }, { "name": "26533acc-3516-445c-9c05-3b0e18686f38:panel_26533acc-3516-445c-9c05-3b0e18686f38", "type": "links", - "id": "16e12160-5bc2-11ee-9a85-7b86504227bc" + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c" }, { "type": "tag", @@ -2843,25 +2979,36 @@ "namespaces": [ "default" ], - "updated_at": "2023-09-25T16:39:48.850Z", - "created_at": "2023-09-25T16:39:48.850Z", - "version": "WzEyMzQsMV0=", + "updated_at": "2023-09-26T17:52:24.244Z", + "created_at": "2023-09-26T17:52:24.244Z", + "version": "WzIxMTAsMV0=", "attributes": { "version": 1, "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + "searchSourceJSON": "{\"query\":{\"query\":\"extension: \\\"links002 filter\\\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":false,\"alias\":\"This filter should only pass from links002 to links001\",\"key\":\"machine.os\",\"field\":\"machine.os\",\"params\":{\"query\":\"ios\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"machine.os\":\"ios\"}},\"$state\":{\"store\":\"appState\"}}]}" }, "description": "", - "timeRestore": false, + "refreshInterval": { + "pause": true, + "value": 60000 + }, + "timeRestore": true, "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":4,\"i\":\"26533acc-3516-445c-9c05-3b0e18686f38\"},\"panelIndex\":\"26533acc-3516-445c-9c05-3b0e18686f38\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_26533acc-3516-445c-9c05-3b0e18686f38\"}]", - "title": "links 002" + "timeFrom": "2018-11-11T00:00:00.000Z", + "title": "links 002", + "timeTo": "2018-11-12T00:00:00.000Z" }, "references": [ + { + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern", + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c" + }, { "name": "26533acc-3516-445c-9c05-3b0e18686f38:panel_26533acc-3516-445c-9c05-3b0e18686f38", "type": "links", - "id": "16e12160-5bc2-11ee-9a85-7b86504227bc" + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c" }, { "type": "tag", @@ -2880,19 +3027,21 @@ "namespaces": [ "default" ], - "updated_at": "2023-09-25T16:42:56.124Z", - "created_at": "2023-09-25T16:42:56.124Z", - "version": "WzEyMzksMV0=", + "updated_at": "2023-09-26T16:30:45.296Z", + "created_at": "2023-09-26T16:30:45.296Z", + "version": "WzIwOTEsMV0=", "attributes": { "version": 1, "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" }, "description": "", - "timeRestore": false, + "timeRestore": true, "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", "panelsJSON": "[{\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":3,\"i\":\"12e3dea4-44cc-405e-99de-408c5879ab55\"},\"panelIndex\":\"12e3dea4-44cc-405e-99de-408c5879ab55\",\"embeddableConfig\":{\"savedVis\":{\"id\":\"\",\"title\":\"\",\"description\":\"\",\"type\":\"markdown\",\"params\":{\"fontSize\":12,\"openLinksInNewTab\":false,\"markdown\":\"This panel appears at the top\"},\"uiState\":{},\"data\":{\"aggs\":[],\"searchSource\":{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}}},\"enhancements\":{}}}]", - "title": "links 003" + "timeFrom": "2018-12-24T00:00:00.000Z", + "title": "links 003", + "timeTo": "2018-12-26T00:00:00.000Z" }, "references": [ { @@ -2905,3 +3054,124 @@ "coreMigrationVersion": "8.8.0", "typeMigrationVersion": "8.9.0" } + +{ + "id": "89566e10-5d4a-11ee-9513-d3f0b68b64f8", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-27T15:29:41.343Z", + "created_at": "2023-09-27T15:29:41.343Z", + "version": "WzIwMDAsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "description": "", + "refreshInterval": { + "pause": true, + "value": 60000 + }, + "timeRestore": true, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":4,\"i\":\"7e4355a2-41e9-4627-982b-66ec7d98d58d\"},\"panelIndex\":\"7e4355a2-41e9-4627-982b-66ec7d98d58d\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_7e4355a2-41e9-4627-982b-66ec7d98d58d\"}]", + "timeFrom": "2018-12-24T00:00:00.000Z", + "title": "links 005", + "timeTo": "2018-12-26T00:00:00.000Z" + }, + "references": [ + { + "name": "7e4355a2-41e9-4627-982b-66ec7d98d58d:panel_7e4355a2-41e9-4627-982b-66ec7d98d58d", + "type": "links", + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c" + }, + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} + +{ + "id": "d8e17750-5d6c-11ee-be0d-9787f0515106", + "type": "links", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-27T19:34:17.157Z", + "created_at": "2023-09-27T19:34:17.157Z", + "version": "WzM0NDAsMV0=", + "attributes": { + "links": [ + { + "label": "opens in new tab", + "type": "externalLink", + "id": "link999", + "destination": "https://example.com/1", + "order": 0 + }, + { + "label": "opens in same tab", + "type": "externalLink", + "id": "link888", + "destination": "https://example.com/2", + "options": { + "openInNewTab": false, + "encodeUrl": true + }, + "order": 1 + }, + { + "label": "external link violation", + "type": "externalLink", + "id": "link777", + "destination": "https://danger.example.com", + "order": 2 + } + ], + "layout": "vertical", + "title": "some external links", + "description": "" + }, + "references": [], + "managed": false, + "coreMigrationVersion": "8.8.0" +} + +{ + "id": "379c1b60-5d6d-11ee-be0d-9787f0515106", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-27T19:36:56.086Z", + "created_at": "2023-09-27T19:36:56.086Z", + "version": "WzM0NDEsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "description": "", + "timeRestore": false, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":9,\"h\":6,\"i\":\"fd19ed9e-8e8b-4769-bb25-0a76923e9f80\"},\"panelIndex\":\"fd19ed9e-8e8b-4769-bb25-0a76923e9f80\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_fd19ed9e-8e8b-4769-bb25-0a76923e9f80\"}]", + "title": "dashboard with external links" + }, + "references": [ + { + "name": "fd19ed9e-8e8b-4769-bb25-0a76923e9f80:panel_fd19ed9e-8e8b-4769-bb25-0a76923e9f80", + "type": "links", + "id": "d8e17750-5d6c-11ee-be0d-9787f0515106" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 6ff48c6b0cfbe..570edce8d96cb 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -612,8 +612,12 @@ export class DashboardPageObject extends FtrService { return visibilities; } + public async getPanels() { + return await this.find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes + } + public async getPanelDimensions() { - const panels = await this.find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes + const panels = await this.getPanels(); return await Promise.all( panels.map(async (panel) => { const size = await panel.getSize(); diff --git a/test/functional/page_objects/dashboard_page_links.ts b/test/functional/page_objects/dashboard_page_links.ts new file mode 100644 index 0000000000000..cfce74bdbe5bf --- /dev/null +++ b/test/functional/page_objects/dashboard_page_links.ts @@ -0,0 +1,193 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { LinksLayoutType } from '@kbn/links-plugin/common/content_management'; +import { FtrService } from '../ftr_provider_context'; + +export class DashboardPageLinks extends FtrService { + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly comboBox = this.ctx.getService('comboBox'); + + private readonly header = this.ctx.getPageObject('header'); + private readonly settings = this.ctx.getPageObject('settings'); + + public async toggleLinksLab(value?: boolean) { + await this.header.clickStackManagement(); + await this.settings.clickKibanaSettings(); + + await this.settings.toggleAdvancedSettingCheckbox('labs:dashboard:linksPanel', value); + } + + /* ----------------------------------------------------------- + Links panel + ----------------------------------------------------------- */ + + public async getAllLinksInPanel() { + const listGroup = await this.testSubjects.find('links--component--listGroup'); + return await listGroup.findAllByCssSelector('li'); + } + + public async getNumberOfLinksInPanel() { + const links = await this.getAllLinksInPanel(); + return links.length; + } + + /* ----------------------------------------------------------- + Links flyout + ----------------------------------------------------------- */ + + public async expectFlyoutIsOpen() { + await this.testSubjects.exists('links--panelEditor--flyout'); + } + + public async clickPanelEditorSaveButton() { + await this.expectFlyoutIsOpen(); + await this.testSubjects.clickWhenNotDisabled('links--panelEditor--saveBtn'); + } + + public async clickLinkEditorCloseButton() { + await this.testSubjects.click('links--linkEditor--closeBtn'); + } + + public async clickPanelEditorCloseButton() { + await this.testSubjects.click('links--panelEditor--closeBtn'); + } + + public async clickLinksEditorSaveButton() { + await this.testSubjects.clickWhenNotDisabled('links--linkEditor--saveBtn'); + } + + public async findDraggableLinkByIndex(index: number) { + await this.testSubjects.exists('links--panelEditor--flyout'); + const linksFormRow = await this.testSubjects.find('links--panelEditor--linksAreaDroppable'); + return await linksFormRow.findByCssSelector( + `[data-test-subj="links--panelEditor--draggableLink"]:nth-child(${index})` + ); + } + + public async addDashboardLink( + destination: string, + useCurrentFilters: boolean = true, + useCurrentDateRange: boolean = true, + openInNewTab: boolean = false, + linkLabel?: string + ) { + await this.expectFlyoutIsOpen(); + await this.testSubjects.click('links--panelEditor--addLinkBtn'); + await this.testSubjects.exists('links--linkEditor--flyout'); + const radioOption = await this.testSubjects.find('links--linkEditor--dashboardLink--radioBtn'); + const label = await radioOption.findByCssSelector('label[for="dashboardLink"]'); + await label.click(); + + await this.comboBox.set('links--linkEditor--dashboardLink--comboBox', destination); + if (linkLabel) { + await this.testSubjects.setValue('links--linkEditor--linkLabel--input', linkLabel); + } + + await this.testSubjects.setEuiSwitch( + 'dashboardDrillDownOptions--useCurrentFilters--checkbox', + useCurrentFilters ? 'check' : 'uncheck' + ); + await this.testSubjects.setEuiSwitch( + 'dashboardDrillDownOptions--useCurrentDateRange--checkbox', + useCurrentDateRange ? 'check' : 'uncheck' + ); + await this.testSubjects.setEuiSwitch( + 'dashboardDrillDownOptions--openInNewTab--checkbox', + openInNewTab ? 'check' : 'uncheck' + ); + + await this.clickLinksEditorSaveButton(); + } + + public async addExternalLink( + destination: string, + openInNewTab: boolean = true, + encodeUrl: boolean = true, + linkLabel?: string + ) { + await this.setExternalUrlInput(destination); + if (linkLabel) { + await this.testSubjects.setValue('links--linkEditor--linkLabel--input', linkLabel); + } + await this.testSubjects.setEuiSwitch( + 'urlDrilldownOpenInNewTab', + openInNewTab ? 'check' : 'uncheck' + ); + await this.testSubjects.setEuiSwitch('urlDrilldownEncodeUrl', encodeUrl ? 'check' : 'uncheck'); + + await this.clickLinksEditorSaveButton(); + } + + public async deleteLinkByIndex(index: number) { + const linkToDelete = await this.findDraggableLinkByIndex(index); + await this.retry.try(async () => { + await linkToDelete.moveMouseTo(); + await this.testSubjects.existOrFail(`panelEditorLink--deleteBtn`); + }); + const deleteButton = await linkToDelete.findByTestSubject(`panelEditorLink--deleteBtn`); + await deleteButton.click(); + } + + public async editLinkByIndex(index: number) { + const linkToEdit = await this.findDraggableLinkByIndex(index); + await this.retry.try(async () => { + await linkToEdit.moveMouseTo(); + await this.testSubjects.existOrFail(`panelEditorLink--editBtn`); + }); + const editButton = await linkToEdit.findByTestSubject(`panelEditorLink--editBtn`); + await editButton.click(); + } + + public async reorderLinks(linkLabel: string, startIndex: number, steps: number, reverse = false) { + const linkToMove = await this.findDraggableLinkByIndex(startIndex); + const draggableButton = await linkToMove.findByTestSubject(`panelEditorLink--dragHandle`); + expect(await draggableButton.getAttribute('data-rfd-drag-handle-draggable-id')).to.equal( + linkLabel + ); + await draggableButton.focus(); + await this.retry.try(async () => { + await this.browser.pressKeys(this.browser.keys.SPACE); + linkToMove.elementHasClass('euiDraggable--isDragging'); + }); + for (let i = 0; i < steps; i++) { + await this.browser.pressKeys(reverse ? this.browser.keys.UP : this.browser.keys.DOWN); + } + await this.browser.pressKeys(this.browser.keys.SPACE); + await this.retry.try(async () => { + expect(await linkToMove.elementHasClass('euiDraggable--isDragging')).to.be(false); + }); + } + + public async setLayout(layout: LinksLayoutType) { + await this.expectFlyoutIsOpen(); + const testSubj = `links--panelEditor--${layout}LayoutBtn`; + await this.testSubjects.click(testSubj); + } + + public async setExternalUrlInput(destination: string) { + await this.expectFlyoutIsOpen(); + await this.testSubjects.click('links--panelEditor--addLinkBtn'); + await this.testSubjects.exists('links--linkEditor--flyout'); + const option = await this.testSubjects.find('links--linkEditor--externalLink--radioBtn'); + const label = await option.findByCssSelector('label[for="externalLink"]'); + await label.click(); + await this.testSubjects.setValue('links--linkEditor--externalLink--input', destination); + } + + public async toggleSaveByReference(checked: boolean) { + await this.expectFlyoutIsOpen(); + await this.testSubjects.setEuiSwitch( + 'links--panelEditor--saveByReferenceSwitch', + checked ? 'check' : 'uncheck' + ); + } +} diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index 9a2312e0fedee..34859cfe943d3 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -31,6 +31,7 @@ import { SavedObjectsPageObject } from './management/saved_objects_page'; import { LegacyDataTableVisPageObject } from './legacy/data_table_vis'; import { IndexPatternFieldEditorPageObject } from './management/indexpattern_field_editor_page'; import { DashboardPageControls } from './dashboard_page_controls'; +import { DashboardPageLinks } from './dashboard_page_links'; import { UnifiedSearchPageObject } from './unified_search_page'; import { UnifiedFieldListPageObject } from './unified_field_list'; import { FilesManagementPageObject } from './files_management'; @@ -43,6 +44,7 @@ export const pageObjects = { context: ContextPageObject, dashboard: DashboardPageObject, dashboardControls: DashboardPageControls, + dashboardLinks: DashboardPageLinks, discover: DiscoverPageObject, error: ErrorPageObject, header: HeaderPageObject, diff --git a/test/tsconfig.json b/test/tsconfig.json index d71c7f9c8ffb1..fb20896356807 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -69,6 +69,7 @@ "@kbn/discover-plugin", "@kbn/core-http-common", "@kbn/event-annotation-plugin", - "@kbn/event-annotation-common" + "@kbn/event-annotation-common", + "@kbn/links-plugin" ] } From 91ae3b02035cb5b049ca58a4662bac703c919c67 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 28 Sep 2023 15:44:00 -0400 Subject: [PATCH 45/53] Fix failing test --- .../apps/dashboard_elements/links/links_create_edit.ts | 2 +- test/functional/page_objects/dashboard_page_links.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/test/functional/apps/dashboard_elements/links/links_create_edit.ts b/test/functional/apps/dashboard_elements/links/links_create_edit.ts index a5d4c8733c195..bdefb60240c48 100644 --- a/test/functional/apps/dashboard_elements/links/links_create_edit.ts +++ b/test/functional/apps/dashboard_elements/links/links_create_edit.ts @@ -103,7 +103,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardLinks.expectFlyoutIsOpen(); // Move the third link up one step - dashboardLinks.reorderLinks('link003', 3, 1, true); + await dashboardLinks.reorderLinks('link003', 3, 1, true); await dashboardLinks.clickPanelEditorSaveButton(); await header.waitUntilLoadingHasFinished(); diff --git a/test/functional/page_objects/dashboard_page_links.ts b/test/functional/page_objects/dashboard_page_links.ts index cfce74bdbe5bf..5055c16818c72 100644 --- a/test/functional/page_objects/dashboard_page_links.ts +++ b/test/functional/page_objects/dashboard_page_links.ts @@ -154,10 +154,8 @@ export class DashboardPageLinks extends FtrService { linkLabel ); await draggableButton.focus(); - await this.retry.try(async () => { - await this.browser.pressKeys(this.browser.keys.SPACE); - linkToMove.elementHasClass('euiDraggable--isDragging'); - }); + await this.browser.pressKeys(this.browser.keys.SPACE); + for (let i = 0; i < steps; i++) { await this.browser.pressKeys(reverse ? this.browser.keys.UP : this.browser.keys.DOWN); } From 9d0873a8d0992c9b8d65c3758f1147b5100c7691 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 28 Sep 2023 15:58:37 -0400 Subject: [PATCH 46/53] Fix expected links length in test --- .../apps/dashboard_elements/links/links_create_edit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/functional/apps/dashboard_elements/links/links_create_edit.ts b/test/functional/apps/dashboard_elements/links/links_create_edit.ts index bdefb60240c48..ad75d0333596a 100644 --- a/test/functional/apps/dashboard_elements/links/links_create_edit.ts +++ b/test/functional/apps/dashboard_elements/links/links_create_edit.ts @@ -145,7 +145,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardLinks.clickPanelEditorSaveButton(); await header.waitUntilLoadingHasFinished(); - expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(5); + expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(4); }); }); }); From 44743fcd5b5dca48f969180ddbf4c0b704d7ecee Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 28 Sep 2023 16:09:28 -0400 Subject: [PATCH 47/53] Give test suites distinctive names --- .../apps/dashboard_elements/links/links_create_edit.ts | 2 +- .../apps/dashboard_elements/links/links_navigation.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/functional/apps/dashboard_elements/links/links_create_edit.ts b/test/functional/apps/dashboard_elements/links/links_create_edit.ts index ad75d0333596a..4a6e94c656bac 100644 --- a/test/functional/apps/dashboard_elements/links/links_create_edit.ts +++ b/test/functional/apps/dashboard_elements/links/links_create_edit.ts @@ -37,7 +37,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const DASHBOARD_NAME = 'Test Links panel'; const LINKS_PANEL_NAME = 'Some links'; - describe('links panel', () => { + describe('links panel create and edit', () => { describe('creation', async () => { before(async () => { await dashboard.navigateToApp(); diff --git a/test/functional/apps/dashboard_elements/links/links_navigation.ts b/test/functional/apps/dashboard_elements/links/links_navigation.ts index 122084894bfdc..7f525f6ffa870 100644 --- a/test/functional/apps/dashboard_elements/links/links_navigation.ts +++ b/test/functional/apps/dashboard_elements/links/links_navigation.ts @@ -23,7 +23,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const FROM_TIME = 'Oct 22, 2018 @ 00:00:00.000'; const TO_TIME = 'Dec 3, 2018 @ 00:00:00.000'; - describe('links panel', () => { + describe('links panel navigation', () => { before(async () => { await kibanaServer.savedObjects.cleanStandardList(); await security.testUser.setRoles([ From 1aa4fc3db993a78765f09028c4a006858f5166c2 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 28 Sep 2023 15:46:43 -0600 Subject: [PATCH 48/53] Remove deprecated functions + fix flyout animation --- .../components/editor/links_editor.scss | 4 +--- .../public/components/editor/links_editor.tsx | 4 ++-- .../public/editor/open_editor_flyout.tsx | 20 ++++++++++++++++--- .../public/editor/open_link_editor_flyout.tsx | 4 ++-- src/plugins/links/tsconfig.json | 5 +++-- 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/plugins/links/public/components/editor/links_editor.scss b/src/plugins/links/public/components/editor/links_editor.scss index c7e03d7b1bb35..3eb0d574ddf27 100644 --- a/src/plugins/links/public/components/editor/links_editor.scss +++ b/src/plugins/links/public/components/editor/links_editor.scss @@ -1,11 +1,9 @@ @import '../../mixins'; .linksPanelEditor { - max-inline-size: $euiSizeXXL * 18; // 40px * 18 = 720px - .linkEditor { @include euiFlyout; - max-inline-size: $euiSizeXXL * 18; + max-inline-size: $euiSizeXXL * 18; // 40px * 18 = 720px &.in { animation: euiFlyoutOpenAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; diff --git a/src/plugins/links/public/components/editor/links_editor.tsx b/src/plugins/links/public/components/editor/links_editor.tsx index 50bd8b7a7316a..92c75f63f11e5 100644 --- a/src/plugins/links/public/components/editor/links_editor.tsx +++ b/src/plugins/links/public/components/editor/links_editor.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EuiForm, @@ -80,7 +80,7 @@ const LinksEditor = ({ isByReference: boolean; }) => { const toasts = coreServices.notifications.toasts; - const editLinkFlyoutRef: React.RefObject = useMemo(() => React.createRef(), []); + const editLinkFlyoutRef = useRef(null); const [currentLayout, setCurrentLayout] = useState( initialLayout ?? LINKS_VERTICAL_LAYOUT diff --git a/src/plugins/links/public/editor/open_editor_flyout.tsx b/src/plugins/links/public/editor/open_editor_flyout.tsx index b6d03b703c74a..f64581300b4b7 100644 --- a/src/plugins/links/public/editor/open_editor_flyout.tsx +++ b/src/plugins/links/public/editor/open_editor_flyout.tsx @@ -11,8 +11,8 @@ import { Subject } from 'rxjs'; import { skip, take, takeUntil } from 'rxjs/operators'; import { withSuspense } from '@kbn/shared-ux-utility'; +import { toMountPoint } from '@kbn/react-kibana-mount'; import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; -import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { LinksInput, LinksByReferenceInput, LinksEditorFlyoutReturn } from '../embeddable/types'; @@ -40,6 +40,19 @@ export async function openEditorFlyout( const attributeService = getLinksAttributeService(); const { attributes } = await attributeService.unwrapAttributes(initialInput); const isByReference = attributeService.inputIsRefType(initialInput); + const initialLinks = attributes?.links; + + if (!initialLinks) { + /** + * When creating a new links panel, the tooltip from the "Add panel" popover interacts badly with the flyout + * and can cause a "double opening" animation if the flyout opens before the tooltip has time to unmount; so, + * when creating a new links panel, we need to slow down the process a little bit so that the tooltip has time + * to disappear before we try to open the flyout. + * + * This does not apply to editing existing links panels, since there is no tooltip for this action. + */ + await new Promise((resolve) => setTimeout(resolve, 100)); + } return new Promise((resolve, reject) => { const closed$ = new Subject(); @@ -103,7 +116,7 @@ export async function openEditorFlyout( const editorFlyout = coreServices.overlays.openFlyout( toMountPoint( , - { theme$: coreServices.theme.theme$ } + { theme: coreServices.theme, i18n: coreServices.i18n } ), { + maxWidth: 720, ownFocus: true, outsideClickCloses: false, onClose: onCancel, diff --git a/src/plugins/links/public/editor/open_link_editor_flyout.tsx b/src/plugins/links/public/editor/open_link_editor_flyout.tsx index fe64ebd0efbe1..e2f111009c3c2 100644 --- a/src/plugins/links/public/editor/open_link_editor_flyout.tsx +++ b/src/plugins/links/public/editor/open_link_editor_flyout.tsx @@ -9,7 +9,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { coreServices } from '../services/kibana_services'; @@ -60,7 +60,7 @@ export async function openLinkEditorFlyout({ }; ReactDOM.render( - + Date: Thu, 28 Sep 2023 15:47:38 -0600 Subject: [PATCH 49/53] Make timeout smaller --- src/plugins/links/public/editor/open_editor_flyout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/links/public/editor/open_editor_flyout.tsx b/src/plugins/links/public/editor/open_editor_flyout.tsx index f64581300b4b7..9a57ff6c936bb 100644 --- a/src/plugins/links/public/editor/open_editor_flyout.tsx +++ b/src/plugins/links/public/editor/open_editor_flyout.tsx @@ -51,7 +51,7 @@ export async function openEditorFlyout( * * This does not apply to editing existing links panels, since there is no tooltip for this action. */ - await new Promise((resolve) => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 50)); } return new Promise((resolve, reject) => { From fe6e862d33de898a764133a98b54dbc59bd58fca Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 28 Sep 2023 15:52:43 -0600 Subject: [PATCH 50/53] Rename a couple components --- .../editor/{links_editor_link.tsx => link_editor.tsx} | 4 ++-- src/plugins/links/public/components/editor/links_editor.tsx | 4 ++-- .../{panel_editor_link.tsx => links_editor_single_link.tsx} | 2 +- src/plugins/links/public/editor/open_link_editor_flyout.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/plugins/links/public/components/editor/{links_editor_link.tsx => link_editor.tsx} (100%) rename src/plugins/links/public/components/editor/{panel_editor_link.tsx => links_editor_single_link.tsx} (99%) diff --git a/src/plugins/links/public/components/editor/links_editor_link.tsx b/src/plugins/links/public/components/editor/link_editor.tsx similarity index 100% rename from src/plugins/links/public/components/editor/links_editor_link.tsx rename to src/plugins/links/public/components/editor/link_editor.tsx index 0d20bf6925acc..5cd4c60870c5e 100644 --- a/src/plugins/links/public/components/editor/links_editor_link.tsx +++ b/src/plugins/links/public/components/editor/link_editor.tsx @@ -35,10 +35,10 @@ import { LinkOptions, Link, } from '../../../common/content_management'; -import { LinkInfo } from '../../embeddable/types'; import { LinksStrings } from '../links_strings'; -import { UnorderedLink } from '../../editor/open_link_editor_flyout'; +import { LinkInfo } from '../../embeddable/types'; import { LinkOptionsComponent } from './link_options'; +import { UnorderedLink } from '../../editor/open_link_editor_flyout'; import { LinkDestination } from './link_destination'; export const LinkEditor = ({ diff --git a/src/plugins/links/public/components/editor/links_editor.tsx b/src/plugins/links/public/components/editor/links_editor.tsx index 92c75f63f11e5..0fb22efaf8507 100644 --- a/src/plugins/links/public/components/editor/links_editor.tsx +++ b/src/plugins/links/public/components/editor/links_editor.tsx @@ -42,8 +42,8 @@ import { coreServices } from '../../services/kibana_services'; import { LinksStrings } from '../links_strings'; import { openLinkEditorFlyout } from '../../editor/open_link_editor_flyout'; import { memoizedGetOrderedLinkList } from '../../editor/links_editor_tools'; -import { PanelEditorLink } from './panel_editor_link'; import { LinksEditorEmptyPrompt } from './links_editor_empty_prompt'; +import { LinksEditorSingleLink } from './links_editor_single_link'; import { TooltipWrapper } from '../tooltip_wrapper'; @@ -215,7 +215,7 @@ const LinksEditor = ({ data-test-subj={`links--panelEditor--draggableLink`} > {(provided) => ( - addOrEditLink(link)} diff --git a/src/plugins/links/public/components/editor/panel_editor_link.tsx b/src/plugins/links/public/components/editor/links_editor_single_link.tsx similarity index 99% rename from src/plugins/links/public/components/editor/panel_editor_link.tsx rename to src/plugins/links/public/components/editor/links_editor_single_link.tsx index 92af155d11753..e6f7bc12ee6a4 100644 --- a/src/plugins/links/public/components/editor/panel_editor_link.tsx +++ b/src/plugins/links/public/components/editor/links_editor_single_link.tsx @@ -30,7 +30,7 @@ import { LinksStrings } from '../links_strings'; import { DashboardLinkStrings } from '../dashboard_link/dashboard_link_strings'; import { DASHBOARD_LINK_TYPE, Link } from '../../../common/content_management'; -export const PanelEditorLink = ({ +export const LinksEditorSingleLink = ({ link, editLink, deleteLink, diff --git a/src/plugins/links/public/editor/open_link_editor_flyout.tsx b/src/plugins/links/public/editor/open_link_editor_flyout.tsx index e2f111009c3c2..85ecb33afaab4 100644 --- a/src/plugins/links/public/editor/open_link_editor_flyout.tsx +++ b/src/plugins/links/public/editor/open_link_editor_flyout.tsx @@ -14,7 +14,7 @@ import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_conta import { coreServices } from '../services/kibana_services'; import { Link } from '../../common/content_management'; -import { LinkEditor } from '../components/editor/links_editor_link'; +import { LinkEditor } from '../components/editor/link_editor'; export interface LinksEditorProps { link?: Link; From 72f0c4d61d767128afc49667f3ac44e53160c3b9 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 28 Sep 2023 16:19:19 -0600 Subject: [PATCH 51/53] Start some easy clean up based on feedback --- .../state/diffing/dashboard_diffing_utils.ts | 4 +--- src/plugins/links/kibana.jsonc | 2 +- src/plugins/links/public/embeddable/links_embeddable.tsx | 4 ++++ src/plugins/links/public/embeddable/links_reducers.ts | 5 ----- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_utils.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_utils.ts index bb0c157017a12..0b6d2db559b5a 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_utils.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_utils.ts @@ -56,9 +56,7 @@ export const getPanelLayoutsAreEqual = ( ]; for (const key of keys) { if (key === undefined) continue; - if (!defaultDiffFunction(originalObj[key], newObj[key])) { - differences[key] = newObj[key]; - } + if (!defaultDiffFunction(originalObj[key], newObj[key])) differences[key] = newObj[key]; } return differences; }; diff --git a/src/plugins/links/kibana.jsonc b/src/plugins/links/kibana.jsonc index a139bbf18a201..5f0796d55b43a 100644 --- a/src/plugins/links/kibana.jsonc +++ b/src/plugins/links/kibana.jsonc @@ -2,7 +2,7 @@ "type": "plugin", "id": "@kbn/links-plugin", "owner": "@elastic/kibana-presentation", - "description": "An dashboard panel for creating links to dashboards or external links.", + "description": "A dashboard panel for creating links to dashboards or external links.", "plugin": { "id": "links", "server": true, diff --git a/src/plugins/links/public/embeddable/links_embeddable.tsx b/src/plugins/links/public/embeddable/links_embeddable.tsx index a7c1dcc0632a3..d7f1b3955289a 100644 --- a/src/plugins/links/public/embeddable/links_embeddable.tsx +++ b/src/plugins/links/public/embeddable/links_embeddable.tsx @@ -52,6 +52,10 @@ export class LinksEmbeddable private subscriptions: Subscription = new Subscription(); // state management + /** + * TODO: Keep track of the necessary state without the redux embeddable tools; it's kind of overkill here. + * Related issue: https://github.com/elastic/kibana/issues/167577 + */ public select: LinksReduxEmbeddableTools['select']; public getState: LinksReduxEmbeddableTools['getState']; public dispatch: LinksReduxEmbeddableTools['dispatch']; diff --git a/src/plugins/links/public/embeddable/links_reducers.ts b/src/plugins/links/public/embeddable/links_reducers.ts index ff96b51fddc5f..659b19058adbb 100644 --- a/src/plugins/links/public/embeddable/links_reducers.ts +++ b/src/plugins/links/public/embeddable/links_reducers.ts @@ -14,11 +14,6 @@ import { LinksReduxState } from './types'; import { LinksAttributes } from '../../common/content_management'; export const linksReducers = { - /** - * TODO: Right now, we aren't using any reducers - but, I'm keeping this here as a draft - * just in case we need them later on. As a final cleanup, we could remove this if we never - * end up using reducers - */ setLoading: (state: WritableDraft, action: PayloadAction) => { state.output.loading = action.payload; }, From f4608e04c1dd41f23315173a07529e75f5919f5c Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 28 Sep 2023 16:54:29 -0600 Subject: [PATCH 52/53] Use dashboard's overlay tracker --- .../public/editor/open_editor_flyout.tsx | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/src/plugins/links/public/editor/open_editor_flyout.tsx b/src/plugins/links/public/editor/open_editor_flyout.tsx index 9a57ff6c936bb..1c722a484eb1d 100644 --- a/src/plugins/links/public/editor/open_editor_flyout.tsx +++ b/src/plugins/links/public/editor/open_editor_flyout.tsx @@ -7,12 +7,11 @@ */ import React from 'react'; -import { Subject } from 'rxjs'; -import { skip, take, takeUntil } from 'rxjs/operators'; import { withSuspense } from '@kbn/shared-ux-utility'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; +import { tracksOverlays } from '@kbn/embeddable-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { LinksInput, LinksByReferenceInput, LinksEditorFlyoutReturn } from '../embeddable/types'; @@ -41,6 +40,7 @@ export async function openEditorFlyout( const { attributes } = await attributeService.unwrapAttributes(initialInput); const isByReference = attributeService.inputIsRefType(initialInput); const initialLinks = attributes?.links; + const overlayTracker = tracksOverlays(parentDashboard) ? parentDashboard : undefined; if (!initialLinks) { /** @@ -55,8 +55,6 @@ export async function openEditorFlyout( } return new Promise((resolve, reject) => { - const closed$ = new Subject(); - const onSaveToLibrary = async (newLinks: Link[], newLayout: LinksLayoutType) => { const newAttributes = { ...attributes, @@ -76,7 +74,7 @@ export async function openEditorFlyout( attributes: newAttributes, }); parentDashboard?.reload(); - editorFlyout.close(); + if (overlayTracker) overlayTracker.clearOverlays(); }; const onAddToDashboard = (newLinks: Link[], newLayout: LinksLayoutType) => { @@ -96,23 +94,14 @@ export async function openEditorFlyout( attributes: newAttributes, }); parentDashboard?.reload(); - editorFlyout.close(); + if (overlayTracker) overlayTracker.clearOverlays(); }; const onCancel = () => { reject(); - editorFlyout.close(); + if (overlayTracker) overlayTracker.clearOverlays(); }; - // Close the flyout whenever the breadcrumbs change - i.e. when the dashboard's title changes, or when - // the user navigates away from the given dashboard (to the listing page **or** to another app), etc. - coreServices.chrome - .getBreadcrumbs$() - .pipe(takeUntil(closed$), skip(1), take(1)) - .subscribe(() => { - editorFlyout.close(); - }); - const editorFlyout = coreServices.overlays.openFlyout( toMountPoint( { - closed$.next(true); - }); + if (overlayTracker) overlayTracker.openOverlay(editorFlyout); }); } From bb35a5efbbc1626047bbd5adacd5ec5594044f55 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Fri, 29 Sep 2023 08:59:52 -0400 Subject: [PATCH 53/53] Review feedback --- src/plugins/links/common/content_management/cm_services.ts | 2 +- src/plugins/links/common/types.ts | 4 ++-- src/plugins/links/public/embeddable/links_embeddable.tsx | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/plugins/links/common/content_management/cm_services.ts b/src/plugins/links/common/content_management/cm_services.ts index fa050138b35ff..8767b7badd9bd 100644 --- a/src/plugins/links/common/content_management/cm_services.ts +++ b/src/plugins/links/common/content_management/cm_services.ts @@ -11,7 +11,7 @@ import type { Version, } from '@kbn/object-versioning'; -// We export the versioned service definition from this file and not the barrel to avoid adding +// We export the versioned service definition from this file and not the index file to avoid adding // the schemas in the "public" js bundle import { serviceDefinition as v1 } from './v1/cm_services'; diff --git a/src/plugins/links/common/types.ts b/src/plugins/links/common/types.ts index b9a42de8f9730..54c14afec77bf 100644 --- a/src/plugins/links/common/types.ts +++ b/src/plugins/links/common/types.ts @@ -7,10 +7,10 @@ */ import type { SavedObjectsResolveResponse } from '@kbn/core-saved-objects-api-server'; +import { CONTENT_ID } from './constants'; -export type LinksContentType = 'links'; +export type LinksContentType = typeof CONTENT_ID; -// TODO does this type need to be versioned? export interface SharingSavedObjectProps { outcome: SavedObjectsResolveResponse['outcome']; aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; diff --git a/src/plugins/links/public/embeddable/links_embeddable.tsx b/src/plugins/links/public/embeddable/links_embeddable.tsx index d7f1b3955289a..863bda323c39b 100644 --- a/src/plugins/links/public/embeddable/links_embeddable.tsx +++ b/src/plugins/links/public/embeddable/links_embeddable.tsx @@ -113,8 +113,6 @@ export class LinksEmbeddable const { attributes } = await this.attributeService.unwrapAttributes(this.getInput()); if (this.isDestroyed) return; - // TODO handle metaInfo - this.dispatch.setAttributes(attributes); await this.initializeOutput();