From 6bb0e7c11b3ed0c0288dc75f1b4211d8daab3e6e Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Fri, 5 Jul 2024 20:59:12 -0400 Subject: [PATCH] [Links] Migrate Links panel to the React embeddable framework (#178670) Fixes #174970 Fixes https://github.com/elastic/kibana/issues/186044 ## Summary Migrates the legacy Links embeddable to the React embeddable framework. Additionally, the new embeddable factory now resolves the dashboards info prior to rendering the Links component. Prior to this change, the `DashboardLinkComponent` would be responsible for asynchronously loading dashboards info and rendering a loading icon. This change reduces the complexity of the `DashboardLinkComponent` as the resolved state is now passed in as props. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Hannah Mudge --- .../embeddable/dashboard_container.tsx | 10 +- .../panel_placement_registry.ts | 6 +- .../panel_placement/types.ts | 3 +- src/plugins/dashboard/public/plugin.tsx | 4 +- .../attribute_service/attribute_service.tsx | 5 +- .../embeddable_placement_registry.ts | 30 ++ src/plugins/links/common/constants.ts | 4 + .../common/content_management/v1/types.ts | 4 +- .../links/common/embeddable/extract.test.ts | 64 --- .../links/common/embeddable/extract.ts | 35 -- src/plugins/links/common/embeddable/index.ts | 10 - .../links/common/embeddable/inject.test.ts | 67 --- src/plugins/links/common/embeddable/inject.ts | 40 -- src/plugins/links/common/embeddable/types.ts | 14 - src/plugins/links/common/index.ts | 2 +- src/plugins/links/common/mocks.tsx | 53 -- src/plugins/links/kibana.jsonc | 1 + .../actions/create_links_panel_action.ts | 52 ++ .../dashboard_link_component.test.tsx | 471 +++++++++++------- .../dashboard_link_component.tsx | 131 ++--- .../dashboard_link_destination_picker.tsx | 11 +- .../dashboard_link/dashboard_link_tools.ts | 2 +- .../public/components/editor/constants.ts | 37 ++ .../components/editor/link_destination.tsx | 20 +- .../public/components/editor/link_editor.tsx | 18 +- .../components/editor/links_editor.scss | 10 +- .../components/editor/links_editor.test.tsx | 37 +- .../public/components/editor/links_editor.tsx | 41 +- .../editor/links_editor_single_link.tsx | 91 +--- .../external_link_component.test.tsx | 54 +- .../external_link/external_link_component.tsx | 34 +- .../public/components/links_component.tsx | 94 ---- .../links/public/components/links_hooks.tsx | 38 -- .../links/public/components/links_strings.ts | 8 +- .../links/public/content_management/index.ts | 2 + .../content_management/load_from_library.ts | 30 ++ .../content_management/save_to_library.tsx | 52 +- .../public/editor/links_editor_tools.tsx | 23 - .../public/editor/open_editor_flyout.tsx | 94 ++-- .../public/editor/open_link_editor_flyout.tsx | 17 +- src/plugins/links/public/embeddable/index.ts | 11 - .../embeddable/links_embeddable.test.tsx | 364 ++++++++++++++ .../public/embeddable/links_embeddable.tsx | 416 ++++++++++------ .../links_embeddable_factory.test.ts | 46 -- .../embeddable/links_embeddable_factory.ts | 156 ------ src/plugins/links/public/embeddable/types.ts | 90 ---- src/plugins/links/public/index.ts | 3 - .../public/lib/deserialize_from_library.ts | 33 ++ .../links/public/lib/resolve_links.test.ts | 73 +++ src/plugins/links/public/lib/resolve_links.ts | 73 +++ .../links/public/lib/serialize_attributes.ts | 32 ++ src/plugins/links/public/mocks.ts | 65 +++ src/plugins/links/public/mocks.tsx | 25 - src/plugins/links/public/plugin.ts | 81 ++- .../public/services/attribute_service.ts | 97 ---- .../links/public/services/kibana_services.ts | 3 + src/plugins/links/public/types.ts | 76 +++ src/plugins/links/tsconfig.json | 9 +- .../links/links_create_edit.ts | 22 +- .../apps/group1/dashboard_links.ts | 2 +- 60 files changed, 1770 insertions(+), 1626 deletions(-) create mode 100644 src/plugins/embeddable/public/lib/embeddable_placement/embeddable_placement_registry.ts delete mode 100644 src/plugins/links/common/embeddable/extract.test.ts delete mode 100644 src/plugins/links/common/embeddable/extract.ts delete mode 100644 src/plugins/links/common/embeddable/index.ts delete mode 100644 src/plugins/links/common/embeddable/inject.test.ts delete mode 100644 src/plugins/links/common/embeddable/inject.ts delete mode 100644 src/plugins/links/common/embeddable/types.ts delete mode 100644 src/plugins/links/common/mocks.tsx create mode 100644 src/plugins/links/public/actions/create_links_panel_action.ts create mode 100644 src/plugins/links/public/components/editor/constants.ts delete mode 100644 src/plugins/links/public/components/links_component.tsx delete mode 100644 src/plugins/links/public/components/links_hooks.tsx create mode 100644 src/plugins/links/public/content_management/load_from_library.ts delete mode 100644 src/plugins/links/public/embeddable/index.ts create mode 100644 src/plugins/links/public/embeddable/links_embeddable.test.tsx delete mode 100644 src/plugins/links/public/embeddable/links_embeddable_factory.test.ts delete mode 100644 src/plugins/links/public/embeddable/links_embeddable_factory.ts delete mode 100644 src/plugins/links/public/embeddable/types.ts create mode 100644 src/plugins/links/public/lib/deserialize_from_library.ts create mode 100644 src/plugins/links/public/lib/resolve_links.test.ts create mode 100644 src/plugins/links/public/lib/resolve_links.ts create mode 100644 src/plugins/links/public/lib/serialize_attributes.ts create mode 100644 src/plugins/links/public/mocks.ts delete mode 100644 src/plugins/links/public/mocks.tsx delete mode 100644 src/plugins/links/public/services/attribute_service.ts create mode 100644 src/plugins/links/public/types.ts diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index 45f3b2535ff6d7..06e1039497f8be 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -498,11 +498,19 @@ export class DashboardContainer if (reactEmbeddableRegistryHasKey(panelPackage.panelType)) { const newId = v4(); + const getCustomPlacementSettingFunc = await getDashboardPanelPlacementSetting( + panelPackage.panelType + ); + + const customPlacementSettings = getCustomPlacementSettingFunc + ? await getCustomPlacementSettingFunc(panelPackage.initialState) + : {}; + const placementSettings = { width: DEFAULT_PANEL_WIDTH, height: DEFAULT_PANEL_HEIGHT, strategy: PanelPlacementStrategy.findTopLeftMostOpenSpace, - ...getDashboardPanelPlacementSetting(panelPackage.panelType)?.(panelPackage.initialState), + ...customPlacementSettings, }; const { width, height, strategy } = placementSettings; diff --git a/src/plugins/dashboard/public/dashboard_container/panel_placement/panel_placement_registry.ts b/src/plugins/dashboard/public/dashboard_container/panel_placement/panel_placement_registry.ts index 98fab7c662506b..7e3036593f1d1d 100644 --- a/src/plugins/dashboard/public/dashboard_container/panel_placement/panel_placement_registry.ts +++ b/src/plugins/dashboard/public/dashboard_container/panel_placement/panel_placement_registry.ts @@ -11,14 +11,14 @@ import { panelPlacementStrings } from '../_dashboard_container_strings'; const registry = new Map>(); -export const registerDashboardPanelPlacementSetting = ( +export const registerDashboardPanelPlacementSetting = ( embeddableType: string, - getPanelPlacementSettings: GetPanelPlacementSettings + getPanelPlacementSettings: GetPanelPlacementSettings ) => { if (registry.has(embeddableType)) { throw new Error(panelPlacementStrings.getPanelPlacementSettingsExistsError(embeddableType)); } - registry.set(embeddableType, getPanelPlacementSettings); + registry.set(embeddableType, getPanelPlacementSettings as GetPanelPlacementSettings); }; export const getDashboardPanelPlacementSetting = (embeddableType: string) => { diff --git a/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts b/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts index 54b490e004fac3..0c70ba6c553a4b 100644 --- a/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts +++ b/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts @@ -7,6 +7,7 @@ */ import { EmbeddableInput } from '@kbn/embeddable-plugin/public'; +import { MaybePromise } from '@kbn/utility-types'; import { DashboardPanelState } from '../../../common'; import { GridData } from '../../../common/content_management'; import { PanelPlacementStrategy } from '../../dashboard_constants'; @@ -40,4 +41,4 @@ export interface IProvidesLegacyPanelPlacementSettings< export type GetPanelPlacementSettings = ( serializedState?: SerializedState -) => PanelPlacementSettings; +) => MaybePromise; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index c2838187d5eca6..344afc4ef73045 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -120,9 +120,9 @@ export interface DashboardStart { locator?: DashboardAppLocator; dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; findDashboardsService: () => Promise; - registerDashboardPanelPlacementSetting: ( + registerDashboardPanelPlacementSetting: ( embeddableType: string, - getPanelPlacementSettings: GetPanelPlacementSettings + getPanelPlacementSettings: GetPanelPlacementSettings ) => void; } diff --git a/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx b/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx index d71410d884d27b..e754975ecd543d 100644 --- a/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx +++ b/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx @@ -20,7 +20,6 @@ import { EmbeddableInput, SavedObjectEmbeddableInput, isSavedObjectEmbeddableInput, - EmbeddableFactoryNotFoundError, EmbeddableFactory, } from '..'; @@ -74,9 +73,7 @@ export class AttributeService< ) { if (getEmbeddableFactory) { const factory = getEmbeddableFactory(this.type); - if (!factory) { - throw new EmbeddableFactoryNotFoundError(this.type); - } + this.embeddableFactory = factory; } } diff --git a/src/plugins/embeddable/public/lib/embeddable_placement/embeddable_placement_registry.ts b/src/plugins/embeddable/public/lib/embeddable_placement/embeddable_placement_registry.ts new file mode 100644 index 00000000000000..82891d2c982b71 --- /dev/null +++ b/src/plugins/embeddable/public/lib/embeddable_placement/embeddable_placement_registry.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +type GetPanelPlacementSettings = (serializedState: StateType) => { + width?: number; + height?: number; + strategy?: string; +}; + +const registry: Map> = new Map(); + +export const registerEmbeddablePlacementStrategy = ( + panelType: string, + getPanelPlacementSettings: GetPanelPlacementSettings +) => { + if (registry.has(panelType)) { + throw new Error(`Embeddable placement for embeddable type ${panelType} already exists`); + } + + registry.set(panelType, getPanelPlacementSettings); +}; + +export const getEmbeddablePlacementStrategy = (panelType: string) => { + return registry.get(panelType); +}; diff --git a/src/plugins/links/common/constants.ts b/src/plugins/links/common/constants.ts index eeba785bf21cda..fa3eadce5a4564 100644 --- a/src/plugins/links/common/constants.ts +++ b/src/plugins/links/common/constants.ts @@ -17,3 +17,7 @@ export const APP_ICON = 'link'; export const APP_NAME = i18n.translate('links.visTypeAlias.title', { defaultMessage: 'Links', }); + +export const DISPLAY_NAME = i18n.translate('links.displayName', { + defaultMessage: 'links', +}); diff --git a/src/plugins/links/common/content_management/v1/types.ts b/src/plugins/links/common/content_management/v1/types.ts index 880bcbc67dd1d6..774d76a6553bdd 100644 --- a/src/plugins/links/common/content_management/v1/types.ts +++ b/src/plugins/links/common/content_management/v1/types.ts @@ -24,7 +24,7 @@ import { export type LinksCrudTypes = ContentManagementCrudTypes< LinksContentType, - LinksAttributes, + Omit & { title: string }, // saved object attributes always have a title Pick, Pick, { @@ -60,7 +60,7 @@ export type LinksLayoutType = typeof LINKS_HORIZONTAL_LAYOUT | typeof LINKS_VERT // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type LinksAttributes = { - title: string; + title?: string; description?: string; links?: Link[]; layout?: LinksLayoutType; diff --git a/src/plugins/links/common/embeddable/extract.test.ts b/src/plugins/links/common/embeddable/extract.test.ts deleted file mode 100644 index 8653a3d650d706..00000000000000 --- a/src/plugins/links/common/embeddable/extract.test.ts +++ /dev/null @@ -1,64 +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 { extract } from './extract'; - -test('Should return original state and empty references with by-reference embeddable state', () => { - const linksByReferenceInput = { - id: '2192e502-0ec7-4316-82fb-c9bbf78525c4', - type: 'links', - }; - - expect(extract!(linksByReferenceInput)).toEqual({ - state: linksByReferenceInput, - references: [], - }); -}); - -test('Should update state with refNames with by-value embeddable state', () => { - const linksByValueInput = { - 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: 'links', - }; - - expect(extract!(linksByValueInput)).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: 'links', - }, - }); -}); diff --git a/src/plugins/links/common/embeddable/extract.ts b/src/plugins/links/common/embeddable/extract.ts deleted file mode 100644 index 5fe842e4316b15..00000000000000 --- a/src/plugins/links/common/embeddable/extract.ts +++ /dev/null @@ -1,35 +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 { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; -import type { LinksAttributes } from '../content_management'; -import { extractReferences } from '../persistable_state'; -import { LinksPersistableState } from './types'; - -export const extract: EmbeddableRegistryDefinition['extract'] = (state) => { - const typedState = state as LinksPersistableState; - - // 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 LinksAttributes, - }); - - return { - state: { - ...state, - attributes, - }, - references, - }; -}; diff --git a/src/plugins/links/common/embeddable/index.ts b/src/plugins/links/common/embeddable/index.ts deleted file mode 100644 index c526b0bf9bff89..00000000000000 --- a/src/plugins/links/common/embeddable/index.ts +++ /dev/null @@ -1,10 +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 { inject } from './inject'; -export { extract } from './extract'; diff --git a/src/plugins/links/common/embeddable/inject.test.ts b/src/plugins/links/common/embeddable/inject.test.ts deleted file mode 100644 index 4fdef93f8e3a92..00000000000000 --- a/src/plugins/links/common/embeddable/inject.test.ts +++ /dev/null @@ -1,67 +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 { inject } from './inject'; - -test('Should return original state with by-reference embeddable state', () => { - const linksByReferenceInput = { - id: 'ea40fd4e-216c-49a7-917f-f733c8a2c817', - type: 'links', - }; - - const references = [ - { - name: 'panel_ea40fd4e-216c-49a7-917f-f733c8a2c817', - type: 'links', - id: '7f92d7d0-8e5f-11ec-9477-312c8a6de896', - }, - ]; - - expect(inject!(linksByReferenceInput, references)).toEqual(linksByReferenceInput); -}); - -test('Should inject refNames with by-value embeddable state', () => { - const linksByValueInput = { - 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: 'links', - }; - const references = [ - { - name: 'link_fc7b8c70-2eb9-40b2-936d-457d1721a438_dashboard', - type: 'dashboard', - id: 'elastic_agent-1a4e7280-6b5e-11ed-98de-67bdecd21824', - }, - ]; - - expect(inject!(linksByValueInput, 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: 'links', - }); -}); diff --git a/src/plugins/links/common/embeddable/inject.ts b/src/plugins/links/common/embeddable/inject.ts deleted file mode 100644 index 134a508406361f..00000000000000 --- a/src/plugins/links/common/embeddable/inject.ts +++ /dev/null @@ -1,40 +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 { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; -import { LinksAttributes } from '../content_management'; -import { injectReferences } from '../persistable_state'; -import { LinksPersistableState } from './types'; - -export const inject: EmbeddableRegistryDefinition['inject'] = (state, references) => { - const typedState = state as LinksPersistableState; - - // 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 LinksAttributes, - 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 links panel. - // Users can then manually edit links to resolve any problems. - return typedState; - } -}; diff --git a/src/plugins/links/common/embeddable/types.ts b/src/plugins/links/common/embeddable/types.ts deleted file mode 100644 index b916d34f70840c..00000000000000 --- a/src/plugins/links/common/embeddable/types.ts +++ /dev/null @@ -1,14 +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 { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; -import { SerializableRecord } from '@kbn/utility-types'; - -export type LinksPersistableState = EmbeddableStateWithType & { - attributes: SerializableRecord; -}; diff --git a/src/plugins/links/common/index.ts b/src/plugins/links/common/index.ts index 9cb4fc42124aac..85bb10e1da7901 100644 --- a/src/plugins/links/common/index.ts +++ b/src/plugins/links/common/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from './constants'; +export { APP_ICON, APP_NAME, CONTENT_ID, DISPLAY_NAME, LATEST_VERSION } from './constants'; diff --git a/src/plugins/links/common/mocks.tsx b/src/plugins/links/common/mocks.tsx deleted file mode 100644 index 299f9edcacdc40..00000000000000 --- a/src/plugins/links/common/mocks.tsx +++ /dev/null @@ -1,53 +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 { 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/links/kibana.jsonc b/src/plugins/links/kibana.jsonc index a058db8a03ce2b..4aed94ab567512 100644 --- a/src/plugins/links/kibana.jsonc +++ b/src/plugins/links/kibana.jsonc @@ -14,6 +14,7 @@ "kibanaReact", "kibanaUtils", "presentationUtil", + "uiActions", "uiActionsEnhanced", "visualizations" ], diff --git a/src/plugins/links/public/actions/create_links_panel_action.ts b/src/plugins/links/public/actions/create_links_panel_action.ts new file mode 100644 index 00000000000000..c5b78fe1e318b5 --- /dev/null +++ b/src/plugins/links/public/actions/create_links_panel_action.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 { apiIsPresentationContainer } from '@kbn/presentation-containers'; +import { EmbeddableApiContext } from '@kbn/presentation-publishing'; +import { ADD_PANEL_TRIGGER, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; +import { COMMON_EMBEDDABLE_GROUPING } from '@kbn/embeddable-plugin/public'; +import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; +import { uiActions } from '../services/kibana_services'; +import { serializeLinksAttributes } from '../lib/serialize_attributes'; +import { LinksSerializedState } from '../types'; + +const ADD_LINKS_PANEL_ACTION_ID = 'create_links_panel'; + +export const registerCreateLinksPanelAction = () => { + uiActions.registerAction({ + id: ADD_LINKS_PANEL_ACTION_ID, + getIconType: () => APP_ICON, + order: 10, + isCompatible: async ({ embeddable }) => { + return apiIsPresentationContainer(embeddable); + }, + execute: async ({ embeddable }) => { + if (!apiIsPresentationContainer(embeddable)) { + throw new IncompatibleActionError(); + } + const { openEditorFlyout } = await import('../editor/open_editor_flyout'); + const runtimeState = await openEditorFlyout({ + parentDashboard: embeddable, + }); + if (!runtimeState) return; + + const initialState: LinksSerializedState = runtimeState.savedObjectId + ? { savedObjectId: runtimeState.savedObjectId } + : // We should not extract the references when passing initialState to addNewPanel + serializeLinksAttributes(runtimeState, false); + + await embeddable.addNewPanel({ + panelType: CONTENT_ID, + initialState, + }); + }, + grouping: [COMMON_EMBEDDABLE_GROUPING.annotation], + getDisplayName: () => APP_NAME, + }); + uiActions.attachAction(ADD_PANEL_TRIGGER, ADD_LINKS_PANEL_ACTION_ID); +}; diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx index 84e358fdb381c8..1ce99eeaf7e1e4 100644 --- a/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx @@ -8,79 +8,51 @@ import React from 'react'; -import { getDashboardLocatorParamsFromEmbeddable } from '@kbn/dashboard-plugin/public'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS } from '@kbn/presentation-util-plugin/public'; -import { createEvent, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { createEvent, fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; -import { mockLinksPanel } from '../../../common/mocks'; -import { LinksContext, LinksEmbeddable } from '../../embeddable/links_embeddable'; import { DashboardLinkComponent } from './dashboard_link_component'; import { DashboardLinkStrings } from './dashboard_link_strings'; -import { fetchDashboard } from './dashboard_link_tools'; +import { getMockLinksParentApi } from '../../mocks'; +import { ResolvedLink } from '../../types'; +import { BehaviorSubject } from 'rxjs'; +import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; -jest.mock('./dashboard_link_tools'); - -jest.mock('@kbn/dashboard-plugin/public', () => { - const originalModule = jest.requireActual('@kbn/dashboard-plugin/public'); - return { - __esModule: true, - ...originalModule, - getDashboardLocatorParamsFromEmbeddable: jest.fn(), +function createMockLinksParent({ + initialQuery, + initialFilters, +}: { + initialQuery?: Query | AggregateQuery; + initialFilters?: Filter[]; +}) { + const parent = { + ...getMockLinksParentApi({ savedObjectId: '456' }), + locator: { + getRedirectUrl: jest.fn().mockReturnValue('https://my-kibana.com/dashboard/123'), + navigate: jest.fn(), + }, + getSerializedStateForChild: jest.fn(), + query$: new BehaviorSubject(initialQuery), + filters$: new BehaviorSubject(initialFilters ?? []), }; -}); + return parent; +} describe('Dashboard link component', () => { - const mockDashboards = [ - { - id: '456', - status: 'success', - attributes: { - title: 'another dashboard', - description: 'something awesome', - panelsJSON: [], - timeRestore: false, - version: '1', - }, - }, - { - id: '123', - status: 'success', - attributes: { - title: 'current dashboard', - description: '', - panelsJSON: [], - timeRestore: false, - version: '1', - }, - }, - ]; - - const defaultLinkInfo = { - destination: '456', - order: 1, + const resolvedLink: ResolvedLink = { id: 'foo', - type: 'dashboardLink' as const, + order: 0, + type: 'dashboardLink', + label: '', + destination: '456', + title: 'Dashboard 1', + description: 'Dashboard 1 description', }; - const onLoading = jest.fn(); - const onRender = jest.fn(); - - let linksEmbeddable: LinksEmbeddable; - let dashboardContainer: DashboardContainer; beforeEach(async () => { window.open = jest.fn(); - (fetchDashboard as jest.Mock).mockResolvedValue(mockDashboards[0]); - linksEmbeddable = await mockLinksPanel({ - dashboardExplicitInput: mockDashboards[1].attributes, - }); - dashboardContainer = linksEmbeddable.parent as DashboardContainer; - dashboardContainer.locator = { - getRedirectUrl: jest.fn().mockReturnValue('https://my-kibana.com/dashboard/123'), - navigate: jest.fn(), - }; }); afterEach(() => { @@ -88,25 +60,18 @@ describe('Dashboard link component', () => { }); test('by default uses navigate to open in same tab', async () => { + const parentApi = createMockLinksParent({}); render( - - - + ); - await waitFor(() => expect(onLoading).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); - expect(fetchDashboard).toHaveBeenCalledWith(defaultLinkInfo.destination); - await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1)); - // renders dashboard title - const link = await screen.findByTestId('dashboardLink--foo'); - expect(link).toHaveTextContent('another dashboard'); + const link = screen.getByTestId('dashboardLink--foo'); + expect(link).toHaveTextContent('Dashboard 1'); // does not render external link icon const externalIcon = within(link).queryByText('External link'); @@ -114,27 +79,27 @@ describe('Dashboard link component', () => { // calls `navigate` on click userEvent.click(link); - expect(dashboardContainer.locator?.getRedirectUrl).toBeCalledWith({ + expect(parentApi.locator?.getRedirectUrl).toBeCalledWith({ dashboardId: '456', + filters: [], + timeRange: { + from: 'now-15m', + to: 'now', + }, }); - expect(dashboardContainer.locator?.navigate).toBeCalledTimes(1); + expect(parentApi.locator?.navigate).toBeCalledTimes(1); }); test('modified click does not trigger event.preventDefault', async () => { + const parentApi = createMockLinksParent({}); render( - - - + ); - await waitFor(() => expect(onLoading).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1)); - const link = await screen.findByTestId('dashboardLink--foo'); + const link = screen.getByTestId('dashboardLink--foo'); const clickEvent = createEvent.click(link, { ctrlKey: true }); const preventDefault = jest.spyOn(clickEvent, 'preventDefault'); fireEvent(link, clickEvent); @@ -142,159 +107,281 @@ describe('Dashboard link component', () => { }); test('openInNewTab uses window.open, not navigateToApp, and renders external icon', async () => { - const linkInfo = { - ...defaultLinkInfo, - options: { ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, openInNewTab: true }, - }; + const parentApi = createMockLinksParent({}); render( - - - + ); - await waitFor(() => expect(onLoading).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); - expect(fetchDashboard).toHaveBeenCalledWith(linkInfo.destination); - await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1)); - const link = await screen.findByTestId('dashboardLink--foo'); + const link = screen.getByTestId('dashboardLink--foo'); expect(link).toBeInTheDocument(); - // external link icon is rendered const externalIcon = within(link).getByText('External link'); expect(externalIcon?.getAttribute('data-euiicon-type')).toBe('popout'); // calls `window.open` userEvent.click(link); - expect(dashboardContainer.locator?.navigate).toBeCalledTimes(0); + expect(parentApi.locator?.navigate).toBeCalledTimes(0); expect(window.open).toHaveBeenCalledWith('https://my-kibana.com/dashboard/123', '_blank'); }); - test('passes linkOptions to getDashboardLocatorParamsFromEmbeddable', async () => { - const linkInfo = { - ...defaultLinkInfo, - options: { - ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, - useCurrentFilters: false, - useCurrentTimeRange: false, - useCurrentDateRange: false, + test('passes query, filters, and timeRange to locator.getRedirectUrl by default', async () => { + const initialFilters = [ + { + query: { match_phrase: { foo: 'bar' } }, + meta: { alias: null, disabled: false, negate: false }, }, - }; + ]; + const initialQuery = { query: 'fiddlesticks: "*"', language: 'lucene' }; + const parentApi = createMockLinksParent({ + initialQuery, + initialFilters, + }); + + parentApi.timeRange$ = new BehaviorSubject({ + from: 'now-7d', + to: 'now', + }); + + render( + + ); + expect(parentApi.locator?.getRedirectUrl).toBeCalledWith({ + dashboardId: '456', + timeRange: { from: 'now-7d', to: 'now' }, + filters: initialFilters, + query: initialQuery, + }); + }); + + test('does not pass timeRange to locator.getRedirectUrl if useCurrentDateRange is false', async () => { + const initialFilters = [ + { + query: { match_phrase: { foo: 'bar' } }, + meta: { alias: null, disabled: false, negate: false }, + }, + ]; + const initialQuery = { query: 'fiddlesticks: "*"', language: 'lucene' }; + const parentApi = createMockLinksParent({ + initialQuery, + initialFilters, + }); + + parentApi.timeRange$ = new BehaviorSubject({ + from: 'now-7d', + to: 'now', + }); + render( - - - + ); - await waitFor(() => expect(onLoading).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); - expect(getDashboardLocatorParamsFromEmbeddable).toHaveBeenCalledWith( - linksEmbeddable, - linkInfo.options + expect(parentApi.locator?.getRedirectUrl).toBeCalledWith({ + dashboardId: '456', + filters: initialFilters, + query: initialQuery, + }); + }); + + test('does not pass filters or query to locator.getRedirectUrl if useCurrentFilters is false', async () => { + const initialFilters = [ + { + query: { match_phrase: { foo: 'bar' } }, + meta: { alias: null, disabled: false, negate: false }, + }, + ]; + const initialQuery = { query: 'fiddlesticks: "*"', language: 'lucene' }; + const parentApi = createMockLinksParent({ + initialQuery, + initialFilters, + }); + + parentApi.timeRange$ = new BehaviorSubject({ + from: 'now-7d', + to: 'now', + }); + + render( + ); - await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1)); + expect(parentApi.locator?.getRedirectUrl).toBeCalledWith({ + dashboardId: '456', + timeRange: { from: 'now-7d', to: 'now' }, + filters: [], + }); }); test('shows an error when fetchDashboard fails', async () => { - (fetchDashboard as jest.Mock).mockRejectedValue(new Error('some error')); - const linkInfo = { - ...defaultLinkInfo, - id: 'notfound', - }; + const parentApi = createMockLinksParent({}); + render( - - - + ); - await waitFor(() => expect(onLoading).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1)); - const link = await screen.findByTestId('dashboardLink--notfound--error'); + const link = await screen.findByTestId('dashboardLink--foo--error'); expect(link).toHaveTextContent(DashboardLinkStrings.getDashboardErrorLabel()); }); test('current dashboard is not a clickable href', async () => { - const linkInfo = { - ...defaultLinkInfo, - destination: '123', - id: 'bar', - }; + const parentApi = createMockLinksParent({}); + parentApi.savedObjectId = new BehaviorSubject('123'); + parentApi.panelTitle = new BehaviorSubject('current dashboard'); + render( - - - + ); - await waitFor(() => expect(onLoading).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1)); - const link = await screen.findByTestId('dashboardLink--bar'); + + const link = screen.getByTestId('dashboardLink--bar'); expect(link).toHaveTextContent('current dashboard'); userEvent.click(link); - expect(dashboardContainer.locator?.navigate).toBeCalledTimes(0); + expect(parentApi.locator?.navigate).toBeCalledTimes(0); expect(window.open).toBeCalledTimes(0); }); test('shows dashboard title and description in tooltip', async () => { + const parentApi = createMockLinksParent({}); + render( - - - + ); - await waitFor(() => expect(onLoading).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1)); - const link = await screen.findByTestId('dashboardLink--foo'); + + const link = screen.getByTestId('dashboardLink--foo'); userEvent.hover(link); const tooltip = await screen.findByTestId('dashboardLink--foo--tooltip'); expect(tooltip).toHaveTextContent('another dashboard'); // title expect(tooltip).toHaveTextContent('something awesome'); // description }); + test('current dashboard title updates when parent changes', async () => { + const parentApi = { + ...createMockLinksParent({}), + panelTitle: new BehaviorSubject('old title'), + panelDescription: new BehaviorSubject('old description'), + savedObjectId: new BehaviorSubject('123'), + }; + + const { rerender } = render( + + ); + expect(await screen.findByTestId('dashboardLink--bar')).toHaveTextContent('old title'); + + parentApi.panelTitle.next('new title'); + rerender( + + ); + expect(await screen.findByTestId('dashboardLink--bar')).toHaveTextContent('new title'); + }); + test('can override link label', async () => { const label = 'my custom label'; - const linkInfo = { - ...defaultLinkInfo, - label, - }; + const parentApi = createMockLinksParent({}); render( - - - + ); - await waitFor(() => expect(onLoading).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1)); - const link = await screen.findByTestId('dashboardLink--foo'); + const link = screen.getByTestId('dashboardLink--foo'); expect(link).toHaveTextContent(label); userEvent.hover(link); const tooltip = await screen.findByTestId('dashboardLink--foo--tooltip'); expect(tooltip).toHaveTextContent(label); }); + + test('can override link label for the current dashboard', async () => { + const customLabel = 'my new label for the current dashboard'; + const parentApi = createMockLinksParent({}); + parentApi.savedObjectId = new BehaviorSubject('123'); + + render( + + ); + + const link = screen.getByTestId('dashboardLink--bar'); + expect(link).toHaveTextContent(customLabel); + }); }); 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 202a697cd71605..39e91bfa4c34b2 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 @@ -7,69 +7,51 @@ */ import classNames from 'classnames'; -import React, { useEffect, useMemo, useState } from 'react'; -import useAsync from 'react-use/lib/useAsync'; -import useObservable from 'react-use/lib/useObservable'; +import React, { useMemo } from 'react'; -import { EuiButtonEmpty, EuiListGroupItem } from '@elastic/eui'; +import { EuiListGroupItem } from '@elastic/eui'; import { METRIC_TYPE } from '@kbn/analytics'; -import { - DashboardLocatorParams, - getDashboardLocatorParamsFromEmbeddable, -} from '@kbn/dashboard-plugin/public'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { DashboardLocatorParams } from '@kbn/dashboard-plugin/public'; import { DashboardDrilldownOptions, DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, } from '@kbn/presentation-util-plugin/public'; -import type { HasParentApi, PublishesUnifiedSearch } from '@kbn/presentation-publishing'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import { isFilterPinned, Query } from '@kbn/es-query'; import { DASHBOARD_LINK_TYPE, - Link, LinksLayoutType, LINKS_VERTICAL_LAYOUT, } from '../../../common/content_management'; import { trackUiMetric } from '../../services/kibana_services'; -import { useLinks } from '../links_hooks'; import { DashboardLinkStrings } from './dashboard_link_strings'; -import { fetchDashboard } from './dashboard_link_tools'; +import { LinksParentApi, ResolvedLink } from '../../types'; export const DashboardLinkComponent = ({ link, layout, - onLoading, - onRender, + parentApi, }: { - link: Link; + link: ResolvedLink; layout: LinksLayoutType; - onLoading: () => void; - onRender: () => void; + parentApi: LinksParentApi; }) => { - const linksEmbeddable = useLinks(); - const [error, setError] = useState(); - - const dashboardContainer = linksEmbeddable.parent as DashboardContainer; - 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) { - /** - * 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. - */ - const dashboard = await fetchDashboard(link.destination) - .then((result) => { - setError(undefined); - return result; - }) - .catch((e) => setError(e)); - return dashboard; - } - }, [link, parentDashboardId]); + const [ + parentDashboardId, + parentDashboardTitle, + parentDashboardDescription, + timeRange, + filters, + query, + ] = useBatchedPublishingSubjects( + parentApi.savedObjectId, + parentApi.panelTitle, + parentApi.panelDescription, + parentApi.timeRange$, + parentApi.filters$, + parentApi.query$ + ); /** * Returns the title and description of the dashboard that the link points to; note that, if the link points to @@ -78,9 +60,9 @@ export const DashboardLinkComponent = ({ */ const [dashboardTitle, dashboardDescription] = useMemo(() => { return link.destination === parentDashboardId - ? [parentDashboardInput?.title, parentDashboardInput?.description] - : [destinationDashboard?.attributes.title, destinationDashboard?.attributes.description]; - }, [link.destination, parentDashboardId, parentDashboardInput, destinationDashboard]); + ? [parentDashboardTitle, parentDashboardDescription] + : [link.title, link.description]; + }, [link, parentDashboardId, parentDashboardTitle, parentDashboardDescription]); /** * Memoized link information @@ -90,17 +72,17 @@ export const DashboardLinkComponent = ({ }, [link, dashboardTitle]); const { tooltipTitle, tooltipMessage } = useMemo(() => { - if (error) { + if (link.error) { return { tooltipTitle: DashboardLinkStrings.getDashboardErrorLabel(), - tooltipMessage: error.message, + tooltipMessage: link.error.message, }; } return { tooltipTitle: Boolean(dashboardDescription) ? linkLabel : undefined, tooltipMessage: dashboardDescription || linkLabel, }; - }, [error, linkLabel, dashboardDescription]); + }, [link, linkLabel, dashboardDescription]); /** * Dashboard-to-dashboard navigation @@ -116,15 +98,18 @@ export const DashboardLinkComponent = ({ const params: DashboardLocatorParams = { dashboardId: link.destination, - ...getDashboardLocatorParamsFromEmbeddable( - linksEmbeddable as Partial< - PublishesUnifiedSearch & HasParentApi> - >, - linkOptions - ), }; + if (linkOptions.useCurrentFilters && query) { + params.query = query as Query; + } + + if (linkOptions.useCurrentDateRange && timeRange) { + params.timeRange = timeRange; + } - const locator = dashboardContainer.locator; + params.filters = linkOptions.useCurrentFilters ? filters : filters?.filter(isFilterPinned); + + const locator = parentApi.locator; if (!locator) return; const href = locator.getRedirectUrl(params); @@ -151,30 +136,24 @@ export const DashboardLinkComponent = ({ } }, }; - }, [link, dashboardContainer.locator, linksEmbeddable, parentDashboardId]); - - useEffect(() => { - if (loadingDestinationDashboard) { - onLoading(); - } else { - onRender(); - } - }, [link, linksEmbeddable, loadingDestinationDashboard, onLoading, onRender]); + }, [ + link.destination, + link.options, + parentDashboardId, + filters, + parentApi.locator, + query, + timeRange, + ]); const id = `dashboardLink--${link.id}`; - return loadingDestinationDashboard ? ( -
  • - - {DashboardLinkStrings.getLoadingDashboardLabel()} - -
  • - ) : ( + return ( ); 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 fea0a5239ba0dc..8868dd1a2776ac 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 @@ -20,9 +20,8 @@ import { EuiFlexGroup, EuiComboBoxOptionOption, } from '@elastic/eui'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { DashboardItem } from '../../embeddable/types'; +import { DashboardItem } from '../../types'; import { DashboardLinkStrings } from './dashboard_link_strings'; import { fetchDashboard, fetchDashboards } from './dashboard_link_tools'; @@ -31,20 +30,18 @@ type DashboardComboBoxOption = EuiComboBoxOptionOption; export const DashboardLinkDestinationPicker = ({ onDestinationPicked, initialSelection, - parentDashboard, + parentDashboardId, onUnmount, ...other }: { initialSelection?: string; - parentDashboard?: DashboardContainer; + parentDashboardId?: string; onUnmount: (dashboardId?: string) => void; onDestinationPicked: (selectedDashboard?: DashboardItem) => void; }) => { const [searchString, setSearchString] = useState(''); const [selectedOption, setSelectedOption] = useState([]); - const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId); - const getDashboardItem = useCallback((dashboard: DashboardItem) => { return { key: dashboard.id, @@ -106,7 +103,7 @@ export const DashboardLinkDestinationPicker = ({ {DashboardLinkStrings.getCurrentDashboardLabel()} )} - + {label} diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts b/src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts index 9081b178153200..fc9d3f893ade61 100644 --- a/src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts @@ -8,7 +8,7 @@ import { isEmpty, filter } from 'lodash'; -import { DashboardItem } from '../../embeddable/types'; +import { DashboardItem } from '../../types'; import { dashboardServices } from '../../services/kibana_services'; /** diff --git a/src/plugins/links/public/components/editor/constants.ts b/src/plugins/links/public/components/editor/constants.ts new file mode 100644 index 00000000000000..a1ce4d742364b8 --- /dev/null +++ b/src/plugins/links/public/components/editor/constants.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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, + LinkType, +} from '../../../common/content_management'; +import { DashboardLinkStrings } from '../dashboard_link/dashboard_link_strings'; +import { ExternalLinkStrings } from '../external_link/external_link_strings'; + +export const LinkInfo: { + [id in LinkType]: { + icon: string; + type: string; + displayName: string; + description: string; + }; +} = { + [DASHBOARD_LINK_TYPE]: { + icon: 'dashboardApp', + type: DashboardLinkStrings.getType(), + displayName: DashboardLinkStrings.getDisplayName(), + description: DashboardLinkStrings.getDescription(), + }, + [EXTERNAL_LINK_TYPE]: { + icon: 'link', + type: ExternalLinkStrings.getType(), + displayName: ExternalLinkStrings.getDisplayName(), + description: ExternalLinkStrings.getDescription(), + }, +}; diff --git a/src/plugins/links/public/components/editor/link_destination.tsx b/src/plugins/links/public/components/editor/link_destination.tsx index 5eb2d67a0d882e..3337a6c4b28622 100644 --- a/src/plugins/links/public/components/editor/link_destination.tsx +++ b/src/plugins/links/public/components/editor/link_destination.tsx @@ -8,8 +8,6 @@ import React, { useState } from 'react'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; - import { EuiFormRow } from '@elastic/eui'; import { LinkType, @@ -24,13 +22,17 @@ import { LinksStrings } from '../links_strings'; export const LinkDestination = ({ link, setDestination, - parentDashboard, + parentDashboardId, selectedLinkType, }: { selectedLinkType: LinkType; - parentDashboard?: DashboardContainer; + parentDashboardId?: string; link?: UnorderedLink; - setDestination: (destination?: string, defaultLabel?: string) => void; + setDestination: ( + destination?: string, + defaultLabel?: string, + defaultDescription?: string + ) => void; }) => { const [destinationError, setDestinationError] = useState(); @@ -60,10 +62,14 @@ export const LinkDestination = ({ setDestination(undefined, undefined); if (selectedDashboardId) setDashboardLinkDestination(selectedDashboardId); }} - parentDashboard={parentDashboard} + parentDashboardId={parentDashboardId} initialSelection={dashboardLinkDestination} onDestinationPicked={(dashboard) => - setDestination(dashboard?.id, dashboard?.attributes.title) + setDestination( + dashboard?.id, + dashboard?.attributes.title, + dashboard?.attributes.description + ) } /> ) : ( diff --git a/src/plugins/links/public/components/editor/link_editor.tsx b/src/plugins/links/public/components/editor/link_editor.tsx index ca3aeda7224bba..47a5d6155d0559 100644 --- a/src/plugins/links/public/components/editor/link_editor.tsx +++ b/src/plugins/links/public/components/editor/link_editor.tsx @@ -26,17 +26,15 @@ import { EuiFlyoutHeader, EuiRadioGroupOption, } from '@elastic/eui'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { LinkType, EXTERNAL_LINK_TYPE, DASHBOARD_LINK_TYPE, LinkOptions, - Link, } from '../../../common/content_management'; import { LinksStrings } from '../links_strings'; -import { LinkInfo } from '../../embeddable/types'; +import { LinkInfo } from './constants'; import { LinkOptionsComponent } from './link_options'; import { UnorderedLink } from '../../editor/open_link_editor_flyout'; import { LinkDestination } from './link_destination'; @@ -45,18 +43,19 @@ export const LinkEditor = ({ link, onSave, onClose, - parentDashboard, + parentDashboardId, }: { onClose: () => void; - parentDashboard?: DashboardContainer; + parentDashboardId?: string; link?: UnorderedLink; // will only be defined if **editing** a link; otherwise, creating a new link - onSave: (newLink: Omit) => void; + onSave: (newLink: UnorderedLink) => void; }) => { const [selectedLinkType, setSelectedLinkType] = useState( link?.type ?? DASHBOARD_LINK_TYPE ); const [defaultLinkLabel, setDefaultLinkLabel] = useState(); const [currentLinkLabel, setCurrentLinkLabel] = useState(link?.label ?? ''); + const [linkDescription, setLinkDescription] = useState(); const [linkOptions, setLinkOptions] = useState(); const [linkDestination, setLinkDestination] = useState(link?.destination); @@ -79,12 +78,13 @@ export const LinkEditor = ({ /** When a new destination is picked, handle the logic for what to display as the current + default labels */ const handleDestinationPicked = useCallback( - (destination?: string, label?: string) => { + (destination?: string, label?: string, description?: string) => { setLinkDestination(destination); if (!currentLinkLabel || defaultLinkLabel === currentLinkLabel) { setCurrentLinkLabel(label ?? ''); } setDefaultLinkLabel(label); + setLinkDescription(description); }, [defaultLinkLabel, currentLinkLabel] ); @@ -124,7 +124,7 @@ export const LinkEditor = ({ @@ -169,6 +169,8 @@ export const LinkEditor = ({ id: link?.id ?? uuidv4(), destination: linkDestination, options: linkOptions, + title: defaultLinkLabel ?? '', + description: linkDescription, }); onClose(); diff --git a/src/plugins/links/public/components/editor/links_editor.scss b/src/plugins/links/public/components/editor/links_editor.scss index 3eb0d574ddf27f..02961c7d5f5cb3 100644 --- a/src/plugins/links/public/components/editor/links_editor.scss +++ b/src/plugins/links/public/components/editor/links_editor.scss @@ -30,18 +30,18 @@ text-decoration: none; } - .linksPanelLinkText { + .linksPanelEditorLinkText { &:hover { text-decoration: underline !important; } } } -.linksPanelLink { +.linksPanelEditorLink { padding: $euiSizeXS $euiSizeS; color: $euiTextColor; - .linksPanelLinkText { + .linksPanelEditorLinkText { flex: 1; min-width: 0; } @@ -49,11 +49,11 @@ &.linkError { border: 1px solid transparentize($euiColorWarningText, .7); - .linksPanelLinkText { + .linksPanelEditorLinkText { color: $euiColorWarningText; } - .linksPanelLinkText--noLabel { + .linksPanelEditorLinkText--noLabel { font-style: italic; } } diff --git a/src/plugins/links/public/components/editor/links_editor.test.tsx b/src/plugins/links/public/components/editor/links_editor.test.tsx index b7c85099cf516a..28a76d24471f34 100644 --- a/src/plugins/links/public/components/editor/links_editor.test.tsx +++ b/src/plugins/links/public/components/editor/links_editor.test.tsx @@ -11,27 +11,8 @@ import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from '@testing-library/react'; import LinksEditor from './links_editor'; import { LinksStrings } from '../links_strings'; -import { Link, LINKS_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: [], - }) - ), - }; -}); +import { LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; +import { ResolvedLink } from '../../types'; describe('LinksEditor', () => { const defaultProps = { @@ -42,30 +23,35 @@ describe('LinksEditor', () => { flyoutId: 'test-id', }; - const someLinks: Link[] = [ + const someLinks: ResolvedLink[] = [ { id: 'foo', type: 'dashboardLink' as const, order: 1, destination: '123', + title: 'dashboard 01', }, { id: 'bar', type: 'dashboardLink' as const, order: 4, destination: '456', + title: 'dashboard 02', + description: 'awesome dashboard if you ask me', }, { id: 'bizz', type: 'externalLink' as const, order: 3, destination: 'http://example.com', + title: 'http://example.com', }, { id: 'buzz', type: 'externalLink' as const, order: 2, destination: 'http://elastic.co', + title: 'Elastic website', }, ]; @@ -88,7 +74,6 @@ describe('LinksEditor', () => { 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('links--panelEditor--title')).toHaveTextContent( LinksStrings.editor.panelEditor.getEditFlyoutTitle() ); @@ -103,9 +88,8 @@ describe('LinksEditor', () => { 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('links--panelEditor--saveBtn'); - await userEvent.click(saveButton); + userEvent.click(saveButton); await waitFor(() => expect(defaultProps.onSaveToLibrary).toHaveBeenCalledTimes(1)); expect(defaultProps.onSaveToLibrary).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT); }); @@ -113,9 +97,8 @@ describe('LinksEditor', () => { 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('links--panelEditor--saveBtn'); - await userEvent.click(saveButton); + userEvent.click(saveButton); expect(defaultProps.onAddToDashboard).toHaveBeenCalledTimes(1); expect(defaultProps.onAddToDashboard).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT); }); diff --git a/src/plugins/links/public/components/editor/links_editor.tsx b/src/plugins/links/public/components/editor/links_editor.tsx index bd2da0041499d6..736c510c17571f 100644 --- a/src/plugins/links/public/components/editor/links_editor.tsx +++ b/src/plugins/links/public/components/editor/links_editor.tsx @@ -28,17 +28,14 @@ import { EuiSwitch, EuiTitle, } from '@elastic/eui'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { - Link, LinksLayoutType, LINKS_HORIZONTAL_LAYOUT, LINKS_VERTICAL_LAYOUT, } from '../../../common/content_management'; -import { focusMainFlyout, memoizedGetOrderedLinkList } from '../../editor/links_editor_tools'; +import { focusMainFlyout } from '../../editor/links_editor_tools'; import { openLinkEditorFlyout } from '../../editor/open_link_editor_flyout'; -import { LinksLayoutInfo } from '../../embeddable/types'; import { coreServices } from '../../services/kibana_services'; import { LinksStrings } from '../links_strings'; import { LinksEditorEmptyPrompt } from './links_editor_empty_prompt'; @@ -47,16 +44,18 @@ import { LinksEditorSingleLink } from './links_editor_single_link'; import { TooltipWrapper } from '../tooltip_wrapper'; import './links_editor.scss'; +import { ResolvedLink } from '../../types'; +import { getOrderedLinkList } from '../../lib/resolve_links'; const layoutOptions: EuiButtonGroupOptionProps[] = [ { id: LINKS_VERTICAL_LAYOUT, - label: LinksLayoutInfo[LINKS_VERTICAL_LAYOUT].displayName, + label: LinksStrings.editor.panelEditor.getVerticalLayoutLabel(), 'data-test-subj': `links--panelEditor--${LINKS_VERTICAL_LAYOUT}LayoutBtn`, }, { id: LINKS_HORIZONTAL_LAYOUT, - label: LinksLayoutInfo[LINKS_HORIZONTAL_LAYOUT].displayName, + label: LinksStrings.editor.panelEditor.getHorizontalLayoutLabel(), 'data-test-subj': `links--panelEditor--${LINKS_HORIZONTAL_LAYOUT}LayoutBtn`, }, ]; @@ -67,16 +66,16 @@ const LinksEditor = ({ onClose, initialLinks, initialLayout, - parentDashboard, + parentDashboardId, isByReference, flyoutId, }: { - onSaveToLibrary: (newLinks: Link[], newLayout: LinksLayoutType) => Promise; - onAddToDashboard: (newLinks: Link[], newLayout: LinksLayoutType) => void; + onSaveToLibrary: (newLinks: ResolvedLink[], newLayout: LinksLayoutType) => Promise; + onAddToDashboard: (newLinks: ResolvedLink[], newLayout: LinksLayoutType) => void; onClose: () => void; - initialLinks?: Link[]; + initialLinks?: ResolvedLink[]; initialLayout?: LinksLayoutType; - parentDashboard?: DashboardContainer; + parentDashboardId?: string; isByReference: boolean; flyoutId: string; // used to manage the focus of this flyout after individual link editor flyout is closed }) => { @@ -88,8 +87,8 @@ const LinksEditor = ({ initialLayout ?? LINKS_VERTICAL_LAYOUT ); const [isSaving, setIsSaving] = useState(false); - const [orderedLinks, setOrderedLinks] = useState([]); - const [saveByReference, setSaveByReference] = useState(!initialLinks ? false : isByReference); + const [orderedLinks, setOrderedLinks] = useState([]); + const [saveByReference, setSaveByReference] = useState(isByReference); const isEditingExisting = initialLinks || isByReference; @@ -98,7 +97,7 @@ const LinksEditor = ({ setOrderedLinks([]); return; } - setOrderedLinks(memoizedGetOrderedLinkList(initialLinks)); + setOrderedLinks(getOrderedLinkList(initialLinks)); }, [initialLinks]); const onDragEnd = useCallback( @@ -116,9 +115,9 @@ const LinksEditor = ({ ); const addOrEditLink = useCallback( - async (linkToEdit?: Link) => { + async (linkToEdit?: ResolvedLink) => { const newLink = await openLinkEditorFlyout({ - parentDashboard, + parentDashboardId, link: linkToEdit, mainFlyoutId: flyoutId, ref: editLinkFlyoutRef, @@ -128,17 +127,20 @@ const LinksEditor = ({ setOrderedLinks( orderedLinks.map((link) => { if (link.id === linkToEdit.id) { - return { ...newLink, order: linkToEdit.order } as Link; + return { ...newLink, order: linkToEdit.order } as ResolvedLink; } return link; }) ); } else { - setOrderedLinks([...orderedLinks, { ...newLink, order: orderedLinks.length } as Link]); + setOrderedLinks([ + ...orderedLinks, + { ...newLink, order: orderedLinks.length } as ResolvedLink, + ]); } } }, - [editLinkFlyoutRef, orderedLinks, parentDashboard, flyoutId] + [editLinkFlyoutRef, orderedLinks, parentDashboardId, flyoutId] ); const hasZeroLinks = useMemo(() => { @@ -213,7 +215,6 @@ const LinksEditor = ({ {(provided) => ( addOrEditLink(link)} deleteLink={() => deleteLink(link.id)} dragHandleProps={provided.dragHandleProps ?? undefined} // casting `null` to `undefined` diff --git a/src/plugins/links/public/components/editor/links_editor_single_link.tsx b/src/plugins/links/public/components/editor/links_editor_single_link.tsx index c69c33662c0149..e030881334cb03 100644 --- a/src/plugins/links/public/components/editor/links_editor_single_link.tsx +++ b/src/plugins/links/public/components/editor/links_editor_single_link.tsx @@ -7,8 +7,7 @@ */ import classNames from 'classnames'; -import React, { useMemo, useState } from 'react'; -import useAsync from 'react-use/lib/useAsync'; +import React, { useMemo } from 'react'; import { EuiText, @@ -18,74 +17,35 @@ import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon, - EuiSkeletonTitle, DraggableProvidedDragHandleProps, } from '@elastic/eui'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { LinkInfo } from '../../embeddable/types'; -import { validateUrl } from '../external_link/external_link_tools'; -import { fetchDashboard } from '../dashboard_link/dashboard_link_tools'; +import { LinkInfo } from './constants'; import { LinksStrings } from '../links_strings'; import { DashboardLinkStrings } from '../dashboard_link/dashboard_link_strings'; -import { DASHBOARD_LINK_TYPE, Link } from '../../../common/content_management'; +import { DASHBOARD_LINK_TYPE } from '../../../common/content_management'; +import { ResolvedLink } from '../../types'; export const LinksEditorSingleLink = ({ link, editLink, deleteLink, - parentDashboard, dragHandleProps, }: { editLink: () => void; deleteLink: () => void; - link: Link; - parentDashboard?: DashboardContainer; + link: ResolvedLink; dragHandleProps?: DraggableProvidedDragHandleProps; }) => { - const [destinationError, setDestinationError] = useState(); - const parentDashboardTitle = parentDashboard?.select((state) => state.explicitInput.title); - const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId); - - const { value: linkLabel, loading: linkLabelLoading } = useAsync(async () => { - if (!link.destination) { - setDestinationError(new Error(DashboardLinkStrings.getDashboardErrorLabel())); - return; - } - - if (link.type === DASHBOARD_LINK_TYPE) { - if (parentDashboardId === link.destination) { - return link.label || parentDashboardTitle; - } else { - const dashboard = await fetchDashboard(link.destination) - .then((result) => { - setDestinationError(undefined); - return result; - }) - .catch((error) => setDestinationError(error)); - return ( - link.label || - (dashboard ? dashboard.attributes.title : DashboardLinkStrings.getDashboardErrorLabel()) - ); - } - } else { - const { valid, message } = validateUrl(link.destination); - if (!valid && message) { - setDestinationError(new Error(message)); - } - return link.label || link.destination; - } - }, [link]); - const LinkLabel = useMemo(() => { const labelText = ( - - - {linkLabel} - - + + {link.label || link.title} + ); return () => - destinationError ? ( + link.error ? ( @@ -148,7 +101,7 @@ export const LinksEditorSingleLink = ({ - + @@ -160,7 +113,7 @@ export const LinksEditorSingleLink = ({ size="xs" iconType="pencil" onClick={editLink} - aria-label={LinksStrings.editor.getEditLinkTitle(linkLabel)} + aria-label={LinksStrings.editor.getEditLinkTitle(link.title)} data-test-subj="panelEditorLink--editBtn" /> @@ -170,7 +123,7 @@ export const LinksEditorSingleLink = ({ { - const defaultLinkInfo = { + const defaultLinkInfo: ResolvedLink = { destination: 'https://example.com', order: 1, id: 'foo', type: 'externalLink' as const, + title: 'https://example.com', }; - let links: LinksEmbeddable; beforeEach(async () => { window.open = jest.fn(); - links = await mockLinksPanel({}); }); afterEach(() => { @@ -38,17 +34,8 @@ describe('external link component', () => { }); test('by default opens in new tab and renders external icon', async () => { - render( - - - - ); + render(); - expect(onRender).toBeCalledTimes(1); const link = await screen.findByTestId('externalLink--foo'); expect(link).toBeInTheDocument(); const externalIcon = within(link).getByText('External link'); @@ -62,11 +49,7 @@ describe('external link component', () => { ...defaultLinkInfo, options: { ...DEFAULT_URL_DRILLDOWN_OPTIONS, openInNewTab: false }, }; - render( - - - - ); + render(); const link = await screen.findByTestId('externalLink--foo'); const externalIcon = within(link).getByText('External link'); expect(externalIcon?.getAttribute('data-euiicon-type')).toBe('popout'); @@ -77,12 +60,8 @@ describe('external link component', () => { ...defaultLinkInfo, options: { ...DEFAULT_URL_DRILLDOWN_OPTIONS, openInNewTab: false }, }; - render( - - - - ); - expect(onRender).toBeCalledTimes(1); + render(); + const link = await screen.findByTestId('externalLink--foo'); expect(link).toHaveTextContent('https://example.com'); const clickEvent = createEvent.click(link, { ctrlKey: true }); @@ -96,12 +75,8 @@ describe('external link component', () => { ...defaultLinkInfo, options: { ...DEFAULT_URL_DRILLDOWN_OPTIONS, openInNewTab: false }, }; - render( - - - - ); - expect(onRender).toBeCalledTimes(1); + render(); + const link = await screen.findByTestId('externalLink--foo'); userEvent.click(link); expect(coreServices.application.navigateToUrl).toBeCalledTimes(1); @@ -112,14 +87,11 @@ describe('external link component', () => { const linkInfo = { ...defaultLinkInfo, destination: 'file://buzz', + error: new Error('URL not supported'), }; - render( - - - - ); - expect(onRender).toBeCalledTimes(1); - const link = await screen.findByTestId('externalLink--foo--error'); + render(); + + const link = screen.getByTestId('externalLink--foo--error'); expect(link).toBeDisabled(); /** * TODO: We should test the tooltip content, but the component is disabled 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 d2209efb8f1006..c60b97115fbf37 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 @@ -6,9 +6,7 @@ * Side Public License, v 1. */ -import React, { useMemo, useState } from 'react'; -import useMount from 'react-use/lib/useMount'; - +import React, { useMemo } from 'react'; import { EuiListGroupItem } from '@elastic/eui'; import { METRIC_TYPE } from '@kbn/analytics'; import { @@ -18,28 +16,19 @@ import { import { EXTERNAL_LINK_TYPE, - Link, LinksLayoutType, LINKS_VERTICAL_LAYOUT, } from '../../../common/content_management'; import { coreServices, trackUiMetric } from '../../services/kibana_services'; -import { validateUrl } from './external_link_tools'; +import { ResolvedLink } from '../../types'; export const ExternalLinkComponent = ({ link, layout, - onRender, }: { - link: Link; + link: ResolvedLink; layout: LinksLayoutType; - onRender: () => void; }) => { - const [error, setError] = useState(); - - useMount(() => { - onRender(); - }); - const linkOptions = useMemo(() => { return { ...DEFAULT_URL_DRILLDOWN_OPTIONS, @@ -47,13 +36,6 @@ export const ExternalLinkComponent = ({ } as UrlDrilldownOptions; }, [link.options]); - const isValidUrl = useMemo(() => { - if (!link.destination) return false; - const { valid, message } = validateUrl(link.destination); - if (!valid) setError(message); - return valid; - }, [link.destination]); - const destination = useMemo(() => { return link.destination && linkOptions.encodeUrl ? encodeURI(link.destination) @@ -67,20 +49,20 @@ export const ExternalLinkComponent = ({ size="s" external color="text" - isDisabled={!link.destination || !isValidUrl} + isDisabled={Boolean(link.error)} className={'linksPanelLink'} - showToolTip={!isValidUrl} + showToolTip={Boolean(link.error)} toolTipProps={{ - content: error, + content: link.error?.message, position: layout === LINKS_VERTICAL_LAYOUT ? 'right' : 'bottom', repositionOnScroll: true, delay: 'long', 'data-test-subj': `${id}--tooltip`, }} - iconType={error ? 'warning' : undefined} + iconType={link.error ? 'warning' : undefined} id={id} label={link.label || link.destination} - data-test-subj={error ? `${id}--error` : `${id}`} + data-test-subj={link.error ? `${id}--error` : `${id}`} href={destination} onClick={async (event) => { if (!destination) return; diff --git a/src/plugins/links/public/components/links_component.tsx b/src/plugins/links/public/components/links_component.tsx deleted file mode 100644 index 0da40365abad0c..00000000000000 --- a/src/plugins/links/public/components/links_component.tsx +++ /dev/null @@ -1,94 +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 { EuiListGroup, EuiPanel } from '@elastic/eui'; -import React, { useEffect, useMemo } from 'react'; -import useMap from 'react-use/lib/useMap'; -import { - DASHBOARD_LINK_TYPE, - LINKS_HORIZONTAL_LAYOUT, - LINKS_VERTICAL_LAYOUT, -} from '../../common/content_management'; -import { memoizedGetOrderedLinkList } from '../editor/links_editor_tools'; -import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; -import { ExternalLinkComponent } from './external_link/external_link_component'; - -import './links_component.scss'; -import { useLinks, useLinksAttributes } from './links_hooks'; - -export const LinksComponent = () => { - const linksEmbeddable = useLinks(); - const linksAttributes = useLinksAttributes(); - - const [linksLoading, { set: setLinkIsLoading }] = useMap( - Object.fromEntries( - (linksAttributes?.links ?? []).map((link) => { - return [link.id, true]; - }) - ) - ); - - useEffect(() => { - if (Object.values(linksLoading).includes(true)) { - linksEmbeddable.onLoading(); - } else { - linksEmbeddable.onRender(); - } - }, [linksLoading, linksEmbeddable]); - - const orderedLinks = useMemo(() => { - if (!linksAttributes?.links) return []; - return memoizedGetOrderedLinkList(linksAttributes?.links); - }, [linksAttributes]); - - const linkItems: { [id: string]: { id: string; content: JSX.Element } } = useMemo(() => { - return (linksAttributes?.links ?? []).reduce((prev, currentLink) => { - return { - ...prev, - [currentLink.id]: { - id: currentLink.id, - content: - currentLink.type === DASHBOARD_LINK_TYPE ? ( - setLinkIsLoading(currentLink.id, true)} - onRender={() => setLinkIsLoading(currentLink.id, false)} - /> - ) : ( - setLinkIsLoading(currentLink.id, false)} - /> - ), - }, - }; - }, {}); - }, [linksAttributes?.links, linksAttributes?.layout, setLinkIsLoading]); - - return ( - - - {orderedLinks.map((link) => linkItems[link.id].content)} - - - ); -}; diff --git a/src/plugins/links/public/components/links_hooks.tsx b/src/plugins/links/public/components/links_hooks.tsx deleted file mode 100644 index aa33c9d0f3ac10..00000000000000 --- a/src/plugins/links/public/components/links_hooks.tsx +++ /dev/null @@ -1,38 +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 { useContext, useEffect, useState } from 'react'; - -import { LinksAttributes } from '../../common/content_management'; -import { LinksContext, LinksEmbeddable } from '../embeddable/links_embeddable'; - -export const useLinks = (): LinksEmbeddable => { - const linksEmbeddable = useContext(LinksContext); - if (linksEmbeddable == null) { - throw new Error('useLinks must be used inside LinksContext.'); - } - return linksEmbeddable!; -}; - -export const useLinksAttributes = (): LinksAttributes | undefined => { - const linksEmbeddable = useLinks(); - const [attributes, setAttributes] = useState( - linksEmbeddable.attributes - ); - - useEffect(() => { - const attributesSubscription = linksEmbeddable.attributes$.subscribe((newAttributes) => { - setAttributes(newAttributes); - }); - return () => { - attributesSubscription.unsubscribe(); - }; - }, [linksEmbeddable.attributes$]); - - return attributes; -}; diff --git a/src/plugins/links/public/components/links_strings.ts b/src/plugins/links/public/components/links_strings.ts index 8c6f1c888fb12f..52b91a81d00548 100644 --- a/src/plugins/links/public/components/links_strings.ts +++ b/src/plugins/links/public/components/links_strings.ts @@ -13,6 +13,12 @@ export const LinksStrings = { i18n.translate('links.description', { defaultMessage: 'Use links to navigate to commonly used dashboards and websites.', }), + embeddable: { + getUnsupportedLinkTypeError: () => + i18n.translate('links.embeddable.unsupportedLinkTypeError', { + defaultMessage: 'Unsupported link type', + }), + }, editor: { getAddButtonLabel: () => i18n.translate('links.editor.addButtonLabel', { @@ -100,7 +106,7 @@ export const LinksStrings = { }), getErrorDuringSaveToastTitle: () => i18n.translate('links.editor.unableToSaveToastTitle', { - defaultMessage: 'Error saving Link panel', + defaultMessage: 'Error saving links panel', }), }, linkEditor: { diff --git a/src/plugins/links/public/content_management/index.ts b/src/plugins/links/public/content_management/index.ts index c7bc84b8f6b805..42e25c5465256d 100644 --- a/src/plugins/links/public/content_management/index.ts +++ b/src/plugins/links/public/content_management/index.ts @@ -8,3 +8,5 @@ export { linksClient } from './links_content_management_client'; export { checkForDuplicateTitle } from './duplicate_title_check'; +export { runSaveToLibrary } from './save_to_library'; +export { loadFromLibrary } from './load_from_library'; diff --git a/src/plugins/links/public/content_management/load_from_library.ts b/src/plugins/links/public/content_management/load_from_library.ts new file mode 100644 index 00000000000000..5c6e54bb702de0 --- /dev/null +++ b/src/plugins/links/public/content_management/load_from_library.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { injectReferences } from '../../common/persistable_state'; +import { linksClient } from './links_content_management_client'; + +export async function loadFromLibrary(savedObjectId: string) { + const { + item: savedObject, + meta: { outcome, aliasPurpose, aliasTargetId }, + } = await linksClient.get(savedObjectId); + if (savedObject.error) throw savedObject.error; + const { attributes } = injectReferences(savedObject); + return { + attributes, + metaInfo: { + sharingSavedObjectProps: { + aliasTargetId, + outcome, + aliasPurpose, + sourceId: savedObjectId, + }, + }, + }; +} 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 e9dba65a532f58..ea731681a7a1ae 100644 --- a/src/plugins/links/public/content_management/save_to_library.tsx +++ b/src/plugins/links/public/content_management/save_to_library.tsx @@ -14,12 +14,11 @@ import { SavedObjectSaveModal, SaveResult, } from '@kbn/saved-objects-plugin/public'; - 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'; +import { linksClient } from './links_content_management_client'; +import { LinksRuntimeState } from '../types'; +import { serializeLinksAttributes } from '../lib/serialize_attributes'; const modalTitle = i18n.translate('links.contentManagement.saveModalTitle', { defaultMessage: `Save {contentId} panel to library`, @@ -29,10 +28,9 @@ const modalTitle = i18n.translate('links.contentManagement.saveModalTitle', { }); export const runSaveToLibrary = async ( - newAttributes: LinksAttributes, - initialInput: LinksInput -): Promise => { - return new Promise((resolve) => { + newState: LinksRuntimeState +): Promise => { + return new Promise((resolve, reject) => { const onSave = async ({ newTitle, newDescription, @@ -47,7 +45,7 @@ export const runSaveToLibrary = async ( if ( !(await checkForDuplicateTitle({ title: newTitle, - lastSavedTitle: newAttributes.title, + lastSavedTitle: newState.title ?? '', copyOnSave: false, onTitleDuplicate, isTitleDuplicateConfirmed, @@ -56,28 +54,40 @@ export const runSaveToLibrary = async ( return {}; } - const stateToSave = { - ...newAttributes, + const { attributes, references } = serializeLinksAttributes(newState); + + const newAttributes = { + ...attributes, ...stateFromSaveModal, }; - const updatedInput = (await getLinksAttributeService().wrapAttributes( - stateToSave, - true, - initialInput - )) as unknown as LinksByReferenceInput; - - resolve(updatedInput); - return { id: updatedInput.savedObjectId }; + try { + const { + item: { id }, + } = await linksClient.create({ + data: { ...newAttributes, title: newTitle }, + options: { references }, + }); + resolve({ + ...newState, + defaultPanelTitle: newTitle, + defaultPanelDescription: newDescription, + savedObjectId: id, + }); + return { id }; + } catch (error) { + reject(); + return { error }; + } }; const saveModal = ( resolve(undefined)} - title={newAttributes.title ?? ''} + title={newState.title ?? ''} customModalTitle={modalTitle} - description={newAttributes.description} + description={newState.description} showDescription showCopyOnSave={false} objectType={CONTENT_ID} diff --git a/src/plugins/links/public/editor/links_editor_tools.tsx b/src/plugins/links/public/editor/links_editor_tools.tsx index 543d08a48ea918..e89bd2ca18f826 100644 --- a/src/plugins/links/public/editor/links_editor_tools.tsx +++ b/src/plugins/links/public/editor/links_editor_tools.tsx @@ -6,29 +6,6 @@ * Side Public License, v 1. */ -import { memoize } from 'lodash'; -import { Link } from '../../common/content_management'; - -const getOrderedLinkList = (links: Link[]): Link[] => { - return [...links].sort((linkA, linkB) => { - return linkA.order - linkB.order; - }); -}; - -/** - * 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: Link[]) => { - return getOrderedLinkList(links); - }, - (links: Link[]) => { - return links; - } -); - /** * Return focus to the main flyout div to align with a11y standards * @param flyoutId ID of the main flyout div element diff --git a/src/plugins/links/public/editor/open_editor_flyout.tsx b/src/plugins/links/public/editor/open_editor_flyout.tsx index 8455ca16e604bb..7d1a1adf9c4785 100644 --- a/src/plugins/links/public/editor/open_editor_flyout.tsx +++ b/src/plugins/links/public/editor/open_editor_flyout.tsx @@ -11,17 +11,17 @@ import { skip, take } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { withSuspense } from '@kbn/shared-ux-utility'; import { OverlayRef } from '@kbn/core-mount-utils-browser'; import { tracksOverlays } from '@kbn/presentation-containers'; -import { Link, LinksLayoutType } from '../../common/content_management'; -import { runSaveToLibrary } from '../content_management/save_to_library'; -import { LinksByReferenceInput, LinksEditorFlyoutReturn, LinksInput } from '../embeddable/types'; -import { getLinksAttributeService } from '../services/attribute_service'; +import { apiPublishesSavedObjectId } from '@kbn/presentation-publishing'; +import { LinksLayoutType } from '../../common/content_management'; +import { linksClient, runSaveToLibrary } from '../content_management'; import { coreServices } from '../services/kibana_services'; +import { LinksRuntimeState, ResolvedLink } from '../types'; +import { serializeLinksAttributes } from '../lib/serialize_attributes'; const LazyLinksEditor = React.lazy(() => import('../components/editor/links_editor')); @@ -35,18 +35,14 @@ const LinksEditor = withSuspense( /** * @throws in case user cancels */ -export async function openEditorFlyout( - initialInput: LinksInput, - parentDashboard?: DashboardContainer -): Promise { - const attributeService = getLinksAttributeService(); - const { attributes } = await attributeService.unwrapAttributes(initialInput); - const isByReference = attributeService.inputIsRefType(initialInput); - const initialLinks = attributes?.links; - const overlayTracker = - parentDashboard && tracksOverlays(parentDashboard) ? parentDashboard : undefined; - - if (!initialLinks) { +export async function openEditorFlyout({ + initialState, + parentDashboard, +}: { + initialState?: LinksRuntimeState; + parentDashboard?: unknown; +}): Promise { + if (!initialState) { /** * 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, @@ -58,6 +54,14 @@ export async function openEditorFlyout( await new Promise((resolve) => setTimeout(resolve, 50)); } + const overlayTracker = + parentDashboard && tracksOverlays(parentDashboard) ? parentDashboard : undefined; + + const parentDashboardId = + parentDashboard && apiPublishesSavedObjectId(parentDashboard) + ? parentDashboard.savedObjectId.value + : undefined; + return new Promise((resolve, reject) => { const flyoutId = `linksEditorFlyout-${uuidv4()}`; @@ -77,45 +81,35 @@ export async function openEditorFlyout( if (!overlayTracker) editorFlyout.close(); }); - const onSaveToLibrary = async (newLinks: Link[], newLayout: LinksLayoutType) => { - const newAttributes = { - ...attributes, + const onSaveToLibrary = async (newLinks: ResolvedLink[], newLayout: LinksLayoutType) => { + const newState: LinksRuntimeState = { + ...initialState, links: newLinks, layout: newLayout, }; - const updatedInput = (initialInput as LinksByReferenceInput).savedObjectId - ? await attributeService.wrapAttributes(newAttributes, true, initialInput) - : await runSaveToLibrary(newAttributes, initialInput); - if (!updatedInput) { - return; - } - resolve({ - newInput: updatedInput, - // pass attributes via attributes so that the Dashboard can choose the right panel size. - attributes: newAttributes, - }); - parentDashboard?.reload(); + if (initialState?.savedObjectId) { + const { attributes, references } = serializeLinksAttributes(newState); + await linksClient.update({ + id: initialState.savedObjectId, + data: attributes, + options: { references }, + }); + resolve(newState); + } else { + const saveResult = await runSaveToLibrary(newState); + resolve(saveResult); + } closeEditorFlyout(editorFlyout); }; - const onAddToDashboard = (newLinks: Link[], newLayout: LinksLayoutType) => { - const newAttributes = { - ...attributes, + const onAddToDashboard = (newLinks: ResolvedLink[], newLayout: LinksLayoutType) => { + const newState = { + ...initialState, links: newLinks, layout: newLayout, }; - const newInput: LinksInput = { - ...initialInput, - attributes: newAttributes, - }; - resolve({ - newInput, - - // pass attributes so that the Dashboard can choose the right panel size. - attributes: newAttributes, - }); - parentDashboard?.reload(); + resolve(newState); closeEditorFlyout(editorFlyout); }; @@ -128,13 +122,13 @@ export async function openEditorFlyout( toMountPoint( , { theme: coreServices.theme, i18n: coreServices.i18n } ), 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 d3406264db4ad5..e6c931be5aee04 100644 --- a/src/plugins/links/public/editor/open_link_editor_flyout.tsx +++ b/src/plugins/links/public/editor/open_link_editor_flyout.tsx @@ -8,18 +8,15 @@ import React from 'react'; import ReactDOM from 'react-dom'; - import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; - import { coreServices } from '../services/kibana_services'; -import { Link } from '../../common/content_management'; import { LinkEditor } from '../components/editor/link_editor'; import { focusMainFlyout } from './links_editor_tools'; +import { ResolvedLink } from '../types'; export interface LinksEditorProps { - link?: Link; - parentDashboard?: DashboardContainer; + link?: ResolvedLink; + parentDashboardId?: string; mainFlyoutId: string; ref: React.RefObject; } @@ -28,7 +25,7 @@ export interface LinksEditorProps { * 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 UnorderedLink = Omit; +export type UnorderedLink = Omit; /** * @throws in case user cancels @@ -37,8 +34,8 @@ export async function openLinkEditorFlyout({ ref, link, mainFlyoutId, // used to manage the focus of this flyout after inidividual link editor flyout is closed - parentDashboard, -}: LinksEditorProps): Promise { + parentDashboardId, +}: LinksEditorProps) { const unmountFlyout = async () => { if (ref.current) { ref.current.children[1].className = 'linkEditor out'; @@ -69,7 +66,7 @@ export async function openLinkEditorFlyout({ link={link} onSave={onSave} onClose={onCancel} - parentDashboard={parentDashboard} + parentDashboardId={parentDashboardId} /> , ref.current diff --git a/src/plugins/links/public/embeddable/index.ts b/src/plugins/links/public/embeddable/index.ts deleted file mode 100644 index ab89b768f12854..00000000000000 --- a/src/plugins/links/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 { LinksEmbeddable } from './links_embeddable'; -export type { LinksFactory } from './links_embeddable_factory'; -export { LinksFactoryDefinition } from './links_embeddable_factory'; diff --git a/src/plugins/links/public/embeddable/links_embeddable.test.tsx b/src/plugins/links/public/embeddable/links_embeddable.test.tsx new file mode 100644 index 00000000000000..73de5b8237df53 --- /dev/null +++ b/src/plugins/links/public/embeddable/links_embeddable.test.tsx @@ -0,0 +1,364 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; +import { setStubKibanaServices } from '@kbn/presentation-panel-plugin/public/mocks'; +import { getLinksEmbeddableFactory } from './links_embeddable'; +import { Link } from '../../common/content_management'; +import { CONTENT_ID } from '../../common'; +import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { + LinksApi, + LinksParentApi, + LinksRuntimeState, + LinksSerializedState, + ResolvedLink, +} from '../types'; +import { linksClient } from '../content_management'; +import { getMockLinksParentApi } from '../mocks'; + +const links: Link[] = [ + { + id: '001', + order: 0, + type: 'dashboardLink', + label: '', + destinationRefName: 'link_001_dashboard', + }, + { + id: '002', + order: 1, + type: 'dashboardLink', + label: 'Dashboard 2', + destinationRefName: 'link_002_dashboard', + }, + { + id: '003', + order: 2, + type: 'externalLink', + label: 'Example homepage', + destination: 'https://example.com', + }, + { + id: '004', + order: 3, + type: 'externalLink', + destination: 'https://elastic.co', + }, +]; + +const resolvedLinks: ResolvedLink[] = [ + { + id: '001', + order: 0, + type: 'dashboardLink', + label: '', + destination: '999', + title: 'Dashboard 1', + description: 'Dashboard 1 description', + }, + { + id: '002', + order: 1, + type: 'dashboardLink', + label: 'Dashboard 2', + destination: '888', + title: 'Dashboard 2', + description: 'Dashboard 2 description', + }, + { + id: '003', + order: 2, + type: 'externalLink', + label: 'Example homepage', + destination: 'https://example.com', + title: 'Example homepage', + }, + { + id: '004', + order: 3, + type: 'externalLink', + destination: 'https://elastic.co', + title: 'https://elastic.co', + }, +]; + +const references = [ + { + id: '999', + name: 'link_001_dashboard', + type: 'dashboard', + }, + { + id: '888', + name: 'link_002_dashboard', + type: 'dashboard', + }, +]; + +jest.mock('../lib/resolve_links', () => { + return { + resolveLinks: jest.fn().mockResolvedValue(resolvedLinks), + }; +}); + +jest.mock('../content_management', () => { + return { + loadFromLibrary: jest.fn((savedObjectId) => { + return Promise.resolve({ + attributes: { + title: 'links 001', + description: 'some links', + links, + layout: 'vertical', + }, + metaInfo: { + sharingSavedObjectProps: { + aliasTargetId: '123', + outcome: 'exactMatch', + aliasPurpose: 'sharing', + sourceId: savedObjectId, + }, + }, + }); + }), + linksClient: { + create: jest.fn().mockResolvedValue({ item: { id: '333' } }), + update: jest.fn().mockResolvedValue({ item: { id: '123' } }), + }, + }; +}); + +describe('getLinksEmbeddableFactory', () => { + const factory = getLinksEmbeddableFactory(); + beforeAll(() => { + const embeddable = embeddablePluginMock.createSetupContract(); + embeddable.registerReactEmbeddableFactory(CONTENT_ID, async () => { + return factory; + }); + setStubKibanaServices(); + }); + + describe('by reference embeddable', () => { + const rawState = { + savedObjectId: '123', + title: 'my links', + description: 'just a few links', + } as LinksSerializedState; + + const expectedRuntimeState = { + defaultPanelTitle: 'links 001', + defaultPanelDescription: 'some links', + layout: 'vertical', + links: resolvedLinks, + description: 'just a few links', + title: 'my links', + savedObjectId: '123', + }; + + let parent: LinksParentApi; + + beforeEach(() => { + parent = getMockLinksParentApi(rawState, references); + }); + + test('deserializeState', async () => { + const deserializedState = await factory.deserializeState({ + rawState, + references, + }); + expect(deserializedState).toEqual({ + ...expectedRuntimeState, + }); + }); + + test('component renders', async () => { + render( + + type={CONTENT_ID} + getParentApi={() => parent} + /> + ); + + expect(await screen.findByTestId('links--component')).toBeInTheDocument(); + }); + + test('api methods', async () => { + const onApiAvailable = jest.fn() as jest.MockedFunction<(api: LinksApi) => void>; + + render( + + type={CONTENT_ID} + onApiAvailable={onApiAvailable} + getParentApi={() => parent} + /> + ); + + await waitFor(async () => { + const api = onApiAvailable.mock.calls[0][0]; + expect(await api.serializeState()).toEqual({ + rawState: { + savedObjectId: '123', + title: 'my links', + description: 'just a few links', + hidePanelTitles: undefined, + }, + references: [], + }); + expect(api.libraryId$.value).toBe('123'); + expect(api.defaultPanelTitle!.value).toBe('links 001'); + expect(api.defaultPanelDescription!.value).toBe('some links'); + }); + }); + + test('unlink from library', async () => { + const onApiAvailable = jest.fn() as jest.MockedFunction<(api: LinksApi) => void>; + + render( + + type={CONTENT_ID} + onApiAvailable={onApiAvailable} + getParentApi={() => parent} + /> + ); + + await waitFor(async () => { + const api = onApiAvailable.mock.calls[0][0]; + api.unlinkFromLibrary(); + expect(await api.serializeState()).toEqual({ + rawState: { + title: 'my links', + description: 'just a few links', + hidePanelTitles: undefined, + attributes: { + description: 'some links', + title: 'links 001', + links, + layout: 'vertical', + }, + }, + references, + }); + expect(api.libraryId$.value).toBeUndefined(); + }); + }); + }); + + describe('by value embeddable', () => { + const rawState = { + attributes: { + links, + layout: 'horizontal', + }, + description: 'just a few links', + title: 'my links', + } as LinksSerializedState; + + const expectedRuntimeState = { + defaultPanelTitle: undefined, + defaultPanelDescription: undefined, + layout: 'horizontal', + links: resolvedLinks, + description: 'just a few links', + title: 'my links', + savedObjectId: undefined, + }; + + let parent: LinksParentApi; + + beforeEach(() => { + parent = getMockLinksParentApi(rawState, references); + }); + + test('deserializeState', async () => { + const deserializedState = await factory.deserializeState({ + rawState, + references, + }); + expect(deserializedState).toEqual({ ...expectedRuntimeState, layout: 'horizontal' }); + }); + + test('component renders', async () => { + render( + + type={CONTENT_ID} + getParentApi={() => parent} + /> + ); + + expect(await screen.findByTestId('links--component')).toBeInTheDocument(); + }); + + test('api methods', async () => { + const onApiAvailable = jest.fn() as jest.MockedFunction<(api: LinksApi) => void>; + + render( + + type={CONTENT_ID} + onApiAvailable={onApiAvailable} + getParentApi={() => parent} + /> + ); + + await waitFor(async () => { + const api = onApiAvailable.mock.calls[0][0]; + expect(await api.serializeState()).toEqual({ + rawState: { + title: 'my links', + description: 'just a few links', + hidePanelTitles: undefined, + attributes: { + links, + layout: 'horizontal', + }, + }, + references, + }); + + expect(api.libraryId$.value).toBeUndefined(); + }); + }); + test('save to library', async () => { + const onApiAvailable = jest.fn() as jest.MockedFunction<(api: LinksApi) => void>; + + render( + + type={CONTENT_ID} + onApiAvailable={onApiAvailable} + getParentApi={() => parent} + /> + ); + + await waitFor(async () => { + const api = onApiAvailable.mock.calls[0][0]; + const newId = await api.saveToLibrary('some new title'); + expect(linksClient.create).toHaveBeenCalledWith({ + data: { + title: 'some new title', + links, + layout: 'horizontal', + }, + options: { references }, + }); + expect(newId).toBe('333'); + expect(api.libraryId$.value).toBe('333'); + expect(await api.serializeState()).toEqual({ + rawState: { + savedObjectId: '333', + title: 'my links', + description: 'just a few links', + hidePanelTitles: undefined, + }, + references: [], + }); + }); + }); + }); +}); diff --git a/src/plugins/links/public/embeddable/links_embeddable.tsx b/src/plugins/links/public/embeddable/links_embeddable.tsx index 523d8706b2b862..0a5b98a7ce3be0 100644 --- a/src/plugins/links/public/embeddable/links_embeddable.tsx +++ b/src/plugins/links/public/embeddable/links_embeddable.tsx @@ -6,155 +6,271 @@ * Side Public License, v 1. */ -import deepEqual from 'fast-deep-equal'; -import React, { createContext } from 'react'; -import { unmountComponentAtNode } from 'react-dom'; -import { distinctUntilChanged, skip, Subject, Subscription, switchMap } from 'rxjs'; +import React, { createContext, useMemo } from 'react'; +import { cloneDeep } from 'lodash'; +import { BehaviorSubject } from 'rxjs'; +import fastIsEqual from 'fast-deep-equal'; +import { EuiListGroup, EuiPanel } from '@elastic/eui'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { PanelIncompatibleError, ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { - AttributeService, - Embeddable, - ReferenceOrValueEmbeddable, - SavedObjectEmbeddableInput, - COMMON_EMBEDDABLE_GROUPING, -} from '@kbn/embeddable-plugin/public'; - -import { CONTENT_ID } from '../../common'; -import { LinksAttributes } from '../../common/content_management'; -import { LinksComponent } from '../components/links_component'; -import { LinksByReferenceInput, LinksByValueInput, LinksInput, LinksOutput } from './types'; - -export const LinksContext = createContext(null); - -export interface LinksConfig { - editable: boolean; -} - -export class LinksEmbeddable - extends Embeddable - implements ReferenceOrValueEmbeddable -{ - public readonly type = CONTENT_ID; - deferEmbeddableLoad = true; - - private domNode?: HTMLElement; - private isDestroyed?: boolean; - private subscriptions: Subscription = new Subscription(); - - public attributes?: LinksAttributes; - public attributes$ = new Subject(); - - public grouping = [COMMON_EMBEDDABLE_GROUPING.annotation]; - - constructor( - config: LinksConfig, - initialInput: LinksInput, - private attributeService: AttributeService, - parent?: DashboardContainer - ) { - super( - initialInput, - { - editable: config.editable, - editableWithExplicitInput: true, - }, - parent - ); - - this.initializeSavedLinks() - .then(() => this.setInitializationFinished()) - .catch((e: Error) => this.onFatalError(e)); - - // By-value panels should update the links attributes when input changes - this.subscriptions.add( - this.getInput$() - .pipe( - distinctUntilChanged(deepEqual), - skip(1), - switchMap(async () => await this.initializeSavedLinks()) - ) - .subscribe() - ); - - // Keep attributes in sync with subject value so it can be used in output - this.subscriptions.add( - this.attributes$.pipe(distinctUntilChanged(deepEqual)).subscribe((attributes) => { - this.attributes = attributes; - }) - ); - } - - private async initializeSavedLinks() { - const { attributes } = await this.attributeService.unwrapAttributes(this.getInput()); - this.attributes$.next(attributes); - await this.initializeOutput(); - } - - private async initializeOutput() { - const { title, description } = this.getInput(); - this.updateOutput({ - defaultTitle: this.attributes?.title, - defaultDescription: this.attributes?.description, - title: title ?? this.attributes?.title, - description: description ?? this.attributes?.description, - }); - } - - public onRender() { - this.renderComplete.dispatchComplete(); - } - - public onLoading() { - this.renderComplete.dispatchInProgress(); - } - - public inputIsRefType( - input: LinksByValueInput | LinksByReferenceInput - ): input is LinksByReferenceInput { - 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; - // By-reference embeddable panels are reloaded when changed, so update the attributes - this.initializeSavedLinks(); - if (this.domNode) { - this.render(this.domNode); - } - } - - public destroy() { - this.isDestroyed = true; - super.destroy(); - this.subscriptions.unsubscribe(); - if (this.domNode) { - unmountComponentAtNode(this.domNode); - } - } - - public render(domNode: HTMLElement) { - this.domNode = domNode; - if (this.isDestroyed) return; - super.render(domNode); - - this.domNode.setAttribute('data-shared-item', ''); - - return ( - - - - ); - } -} + apiPublishesPanelDescription, + apiPublishesPanelTitle, + apiPublishesSavedObjectId, + initializeTitles, + useBatchedOptionalPublishingSubjects, +} from '@kbn/presentation-publishing'; + +import { apiIsPresentationContainer, SerializedPanelState } from '@kbn/presentation-containers'; + +import { + CONTENT_ID, + DASHBOARD_LINK_TYPE, + LinksLayoutType, + LINKS_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, +} from '../../common/content_management'; +import { DashboardLinkComponent } from '../components/dashboard_link/dashboard_link_component'; +import { ExternalLinkComponent } from '../components/external_link/external_link_component'; +import { + LinksApi, + LinksByReferenceSerializedState, + LinksByValueSerializedState, + LinksParentApi, + LinksRuntimeState, + LinksSerializedState, + ResolvedLink, +} from '../types'; +import { DISPLAY_NAME } from '../../common'; +import { injectReferences } from '../../common/persistable_state'; + +import '../components/links_component.scss'; +import { checkForDuplicateTitle, linksClient } from '../content_management'; +import { resolveLinks } from '../lib/resolve_links'; +import { + deserializeLinksSavedObject, + linksSerializeStateIsByReference, +} from '../lib/deserialize_from_library'; +import { serializeLinksAttributes } from '../lib/serialize_attributes'; + +export const LinksContext = createContext(null); + +const isParentApiCompatible = (parentApi: unknown): parentApi is LinksParentApi => + apiIsPresentationContainer(parentApi) && + apiPublishesSavedObjectId(parentApi) && + apiPublishesPanelTitle(parentApi) && + apiPublishesPanelDescription(parentApi); + +export const getLinksEmbeddableFactory = () => { + const linksEmbeddableFactory: ReactEmbeddableFactory< + LinksSerializedState, + LinksRuntimeState, + LinksApi + > = { + type: CONTENT_ID, + deserializeState: async (serializedState) => { + // Clone the state to avoid an object not extensible error when injecting references + const state = cloneDeep(serializedState.rawState); + const { title, description } = serializedState.rawState; + + if (linksSerializeStateIsByReference(state)) { + const attributes = await deserializeLinksSavedObject(state); + return { + ...attributes, + title, + description, + }; + } + + const { attributes: attributesWithInjectedIds } = injectReferences({ + attributes: state.attributes, + references: serializedState.references ?? [], + }); + + const resolvedLinks = await resolveLinks(attributesWithInjectedIds.links ?? []); + + return { + title, + description, + links: resolvedLinks, + layout: attributesWithInjectedIds.layout, + defaultPanelTitle: attributesWithInjectedIds.title, + defaultPanelDescription: attributesWithInjectedIds.description, + }; + }, + buildEmbeddable: async (state, buildApi, uuid, parentApi) => { + const error$ = new BehaviorSubject(state.error); + if (!isParentApiCompatible(parentApi)) error$.next(new PanelIncompatibleError()); + + const links$ = new BehaviorSubject(state.links); + const layout$ = new BehaviorSubject(state.layout); + const defaultPanelTitle = new BehaviorSubject(state.defaultPanelTitle); + const defaultPanelDescription = new BehaviorSubject( + state.defaultPanelDescription + ); + const savedObjectId$ = new BehaviorSubject(state.savedObjectId); + const { titlesApi, titleComparators, serializeTitles } = initializeTitles(state); + + const api = buildApi( + { + ...titlesApi, + blockingError: error$, + defaultPanelTitle, + defaultPanelDescription, + isEditingEnabled: () => Boolean(error$.value === undefined), + libraryId$: savedObjectId$, + getTypeDisplayName: () => DISPLAY_NAME, + getByValueRuntimeSnapshot: () => { + const snapshot = api.snapshotRuntimeState(); + delete snapshot.savedObjectId; + return snapshot; + }, + serializeState: async (): Promise> => { + if (savedObjectId$.value !== undefined) { + const linksByReferenceState: LinksByReferenceSerializedState = { + savedObjectId: savedObjectId$.value, + ...serializeTitles(), + }; + return { rawState: linksByReferenceState, references: [] }; + } + const runtimeState = api.snapshotRuntimeState(); + const { attributes, references } = serializeLinksAttributes(runtimeState); + const linksByValueState: LinksByValueSerializedState = { + attributes, + ...serializeTitles(), + }; + return { rawState: linksByValueState, references }; + }, + saveToLibrary: async (newTitle: string) => { + defaultPanelTitle.next(newTitle); + const runtimeState = api.snapshotRuntimeState(); + const { attributes, references } = serializeLinksAttributes(runtimeState); + const { + item: { id }, + } = await linksClient.create({ + data: { + ...attributes, + title: newTitle, + }, + options: { references }, + }); + savedObjectId$.next(id); + return id; + }, + checkForDuplicateTitle: async ( + newTitle: string, + isTitleDuplicateConfirmed: boolean, + onTitleDuplicate: () => void + ) => { + await checkForDuplicateTitle({ + title: newTitle, + copyOnSave: false, + lastSavedTitle: '', + isTitleDuplicateConfirmed, + onTitleDuplicate, + }); + }, + unlinkFromLibrary: () => { + savedObjectId$.next(undefined); + }, + onEdit: async () => { + try { + const { openEditorFlyout } = await import('../editor/open_editor_flyout'); + const newState = await openEditorFlyout({ + initialState: api.snapshotRuntimeState(), + parentDashboard: parentApi, + }); + if (newState) { + links$.next(newState.links); + layout$.next(newState.layout); + defaultPanelTitle.next(newState.defaultPanelTitle); + defaultPanelDescription.next(newState.defaultPanelDescription); + savedObjectId$.next(newState.savedObjectId); + } + } catch { + // do nothing, user cancelled + } + }, + }, + { + ...titleComparators, + links: [ + links$, + (nextLinks?: ResolvedLink[]) => links$.next(nextLinks ?? []), + (a, b) => Boolean(savedObjectId$.value) || fastIsEqual(a, b), // Editing attributes in a by-reference panel should not trigger unsaved changes. + ], + layout: [ + layout$, + (nextLayout?: LinksLayoutType) => layout$.next(nextLayout ?? LINKS_VERTICAL_LAYOUT), + (a, b) => Boolean(savedObjectId$.value) || a === b, + ], + error: [error$, (nextError?: Error) => error$.next(nextError)], + defaultPanelDescription: [ + defaultPanelDescription, + (nextDescription?: string) => defaultPanelDescription.next(nextDescription), + ], + defaultPanelTitle: [ + defaultPanelTitle, + (nextTitle?: string) => defaultPanelTitle.next(nextTitle), + ], + savedObjectId: [savedObjectId$, (val) => savedObjectId$.next(val)], + } + ); + + const Component = () => { + const [links, layout] = useBatchedOptionalPublishingSubjects(links$, layout$); + + const linkItems: { [id: string]: { id: string; content: JSX.Element } } = useMemo(() => { + if (!links) return {}; + return links.reduce((prev, currentLink) => { + return { + ...prev, + [currentLink.id]: { + id: currentLink.id, + content: + currentLink.type === DASHBOARD_LINK_TYPE ? ( + + ) : ( + + ), + }, + }; + }, {}); + }, [links, layout]); + return ( + + + {links?.map((link) => linkItems[link.id].content)} + + + ); + }; + return { + api, + Component, + }; + }, + }; + return linksEmbeddableFactory; +}; diff --git a/src/plugins/links/public/embeddable/links_embeddable_factory.test.ts b/src/plugins/links/public/embeddable/links_embeddable_factory.test.ts deleted file mode 100644 index d575c975e0295c..00000000000000 --- a/src/plugins/links/public/embeddable/links_embeddable_factory.test.ts +++ /dev/null @@ -1,46 +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 { 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.getLegacyPanelPlacementSettings({} 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.getLegacyPanelPlacementSettings({} 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.getLegacyPanelPlacementSettings({} 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/links/public/embeddable/links_embeddable_factory.ts b/src/plugins/links/public/embeddable/links_embeddable_factory.ts deleted file mode 100644 index 40d377345e4f2e..00000000000000 --- a/src/plugins/links/public/embeddable/links_embeddable_factory.ts +++ /dev/null @@ -1,156 +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 { DASHBOARD_GRID_COLUMN_COUNT, PanelPlacementStrategy } from '@kbn/dashboard-plugin/public'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { IProvidesLegacyPanelPlacementSettings } from '@kbn/dashboard-plugin/public'; -import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; -import { - EmbeddableFactory, - EmbeddableFactoryDefinition, - ErrorEmbeddable, - COMMON_EMBEDDABLE_GROUPING, -} from '@kbn/embeddable-plugin/public'; -import { - GetMigrationFunctionObjectFn, - MigrateFunctionsObject, -} from '@kbn/kibana-utils-plugin/common'; -import { UiActionsPresentableGrouping } from '@kbn/ui-actions-plugin/public'; - -import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; -import { LinksAttributes } from '../../common/content_management'; -import { extract, inject } from '../../common/embeddable'; -import { LinksStrings } from '../components/links_strings'; -import { getLinksAttributeService } from '../services/attribute_service'; -import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; -import type { LinksEmbeddable } from './links_embeddable'; -import { LinksByReferenceInput, LinksEditorFlyoutReturn, LinksInput } from './types'; - -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 getDefaultLinksInput = (): Partial => ({ - disabledActions: ['OPEN_FLYOUT_ADD_DRILLDOWN'], -}); - -const isLinksAttributes = (attributes?: unknown): attributes is LinksAttributes => { - return ( - attributes !== undefined && - Boolean((attributes as LinksAttributes).layout || (attributes as LinksAttributes).links) - ); -}; - -export class LinksFactoryDefinition - implements - EmbeddableFactoryDefinition, - IProvidesLegacyPanelPlacementSettings -{ - latestVersion?: string | undefined; - telemetry?: - | ((state: EmbeddableStateWithType, stats: Record) => Record) - | undefined; - migrations?: MigrateFunctionsObject | GetMigrationFunctionObjectFn | undefined; - grouping: UiActionsPresentableGrouping = [COMMON_EMBEDDABLE_GROUPING.annotation]; - - public readonly type = CONTENT_ID; - - public readonly isContainerType = false; - - public readonly savedObjectMetaData = { - name: APP_NAME, - type: CONTENT_ID, - getIconForSavedObject: () => APP_ICON, - }; - - public getLegacyPanelPlacementSettings: IProvidesLegacyPanelPlacementSettings< - LinksInput, - LinksAttributes | unknown - >['getLegacyPanelPlacementSettings'] = (input, attributes) => { - 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 {}; - } - - const isHorizontal = attributes.layout === 'horizontal'; - const width = isHorizontal ? DASHBOARD_GRID_COLUMN_COUNT : 8; - const height = isHorizontal ? 4 : (attributes.links?.length ?? 1 * 3) + 4; - return { width, height, strategy: PanelPlacementStrategy.placeAtTop }; - }; - - public async isEditable() { - await untilPluginStartServicesReady(); - return Boolean(coreServices.application.capabilities.dashboard?.showWriteControls); - } - - public canCreateNew() { - return true; - } - - public getDefaultInput(): Partial { - return getDefaultLinksInput(); - } - - public async createFromSavedObject( - savedObjectId: string, - input: LinksInput, - parent: DashboardContainer - ): Promise { - if (!(input as LinksByReferenceInput).savedObjectId) { - (input as LinksByReferenceInput).savedObjectId = savedObjectId; - } - return this.create(input, parent); - } - - public async create(initialInput: LinksInput, parent: DashboardContainer) { - await untilPluginStartServicesReady(); - - const { LinksEmbeddable } = await import('./links_embeddable'); - const editable = await this.isEditable(); - - return new LinksEmbeddable( - { editable }, - { ...getDefaultLinksInput(), ...initialInput }, - getLinksAttributeService(), - parent - ); - } - - public async getExplicitInput( - initialInput: LinksInput, - parent?: DashboardContainer - ): Promise { - const { openEditorFlyout } = await import('../editor/open_editor_flyout'); - - const { newInput, attributes } = await openEditorFlyout( - { - ...getDefaultLinksInput(), - ...initialInput, - }, - parent - ); - - return { newInput, attributes }; - } - - public getDisplayName() { - return APP_NAME; - } - - public getIconType() { - return 'link'; - } - - public getDescription() { - return LinksStrings.getDescription(); - } - - inject = inject; - - extract = extract; -} diff --git a/src/plugins/links/public/embeddable/types.ts b/src/plugins/links/public/embeddable/types.ts deleted file mode 100644 index d16d8431a56012..00000000000000 --- a/src/plugins/links/public/embeddable/types.ts +++ /dev/null @@ -1,90 +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 { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; -import { - EmbeddableInput, - EmbeddableOutput, - SavedObjectEmbeddableInput, -} from '@kbn/embeddable-plugin/public'; - -import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; -import { - LinkType, - EXTERNAL_LINK_TYPE, - DASHBOARD_LINK_TYPE, - 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 { LinksStrings } from '../components/links_strings'; - -export const LinksLayoutInfo: { - [id in LinksLayoutType]: { displayName: string }; -} = { - [LINKS_HORIZONTAL_LAYOUT]: { - displayName: LinksStrings.editor.panelEditor.getHorizontalLayoutLabel(), - }, - [LINKS_VERTICAL_LAYOUT]: { - displayName: LinksStrings.editor.panelEditor.getVerticalLayoutLabel(), - }, -}; - -export interface DashboardItem { - id: string; - attributes: DashboardAttributes; -} - -export const LinkInfo: { - [id in LinkType]: { - icon: string; - type: string; - displayName: string; - description: string; - }; -} = { - [DASHBOARD_LINK_TYPE]: { - icon: 'dashboardApp', - type: DashboardLinkStrings.getType(), - displayName: DashboardLinkStrings.getDisplayName(), - description: DashboardLinkStrings.getDescription(), - }, - [EXTERNAL_LINK_TYPE]: { - icon: 'link', - type: ExternalLinkStrings.getType(), - displayName: ExternalLinkStrings.getDisplayName(), - description: ExternalLinkStrings.getDescription(), - }, -}; - -export interface LinksEditorFlyoutReturn { - attributes?: unknown; - newInput: Partial; -} - -export type LinksByValueInput = { - attributes: LinksAttributes; -} & EmbeddableInput; - -export type LinksByReferenceInput = SavedObjectEmbeddableInput; - -export type LinksInput = LinksByValueInput | LinksByReferenceInput; - -export type LinksOutput = EmbeddableOutput & { - attributes?: LinksAttributes; -}; - -/** - * Links embeddable redux state - */ -export type LinksComponentState = LinksAttributes; - -export type LinksReduxState = ReduxEmbeddableState; diff --git a/src/plugins/links/public/index.ts b/src/plugins/links/public/index.ts index 3389cd48f4b672..ce842fc4f1f6dc 100644 --- a/src/plugins/links/public/index.ts +++ b/src/plugins/links/public/index.ts @@ -6,9 +6,6 @@ * Side Public License, v 1. */ -export type { LinksFactory } from './embeddable'; -export { LinksFactoryDefinition, LinksEmbeddable } from './embeddable'; - import { LinksPlugin } from './plugin'; export function plugin() { diff --git a/src/plugins/links/public/lib/deserialize_from_library.ts b/src/plugins/links/public/lib/deserialize_from_library.ts new file mode 100644 index 00000000000000..a35be4f4085549 --- /dev/null +++ b/src/plugins/links/public/lib/deserialize_from_library.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { loadFromLibrary } from '../content_management'; +import { LinksByReferenceSerializedState, LinksSerializedState } from '../types'; +import { resolveLinks } from './resolve_links'; + +export const linksSerializeStateIsByReference = ( + state?: LinksSerializedState +): state is LinksByReferenceSerializedState => { + return Boolean(state && (state as LinksByReferenceSerializedState).savedObjectId !== undefined); +}; + +export const deserializeLinksSavedObject = async (state: LinksByReferenceSerializedState) => { + const { attributes } = await loadFromLibrary(state.savedObjectId); + + const links = await resolveLinks(attributes.links ?? []); + + const { title: defaultPanelTitle, description: defaultPanelDescription, layout } = attributes; + + return { + links, + layout, + savedObjectId: state.savedObjectId, + defaultPanelTitle, + defaultPanelDescription, + }; +}; diff --git a/src/plugins/links/public/lib/resolve_links.test.ts b/src/plugins/links/public/lib/resolve_links.test.ts new file mode 100644 index 00000000000000..e56c28324b6f7a --- /dev/null +++ b/src/plugins/links/public/lib/resolve_links.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { resolveLinkInfo } from './resolve_links'; +import { Link, DASHBOARD_LINK_TYPE } from '../../common/content_management'; + +jest.mock('../components/dashboard_link/dashboard_link_tools', () => ({ + fetchDashboard: async (id: string) => { + if (id === '404') { + const error = new Error('Dashboard not found'); + throw error; + } + return { + attributes: { + title: `Dashboard ${id}`, + description: 'Some descriptive text.', + }, + }; + }, +})); + +describe('resolveLinkInfo', () => { + it('resolves a dashboard link with no label', async () => { + const link: Link = { + id: '1', + type: DASHBOARD_LINK_TYPE, + order: 0, + destination: '001', + }; + const resolvedLink = await resolveLinkInfo(link); + expect(resolvedLink).toEqual({ + title: 'Dashboard 001', + description: 'Some descriptive text.', + label: undefined, + }); + }); + + it('resolves a dashboard link with a label', async () => { + const link: Link = { + id: '1', + type: DASHBOARD_LINK_TYPE, + order: 0, + destination: '001', + label: 'My Dashboard', + }; + const resolvedLink = await resolveLinkInfo(link); + expect(resolvedLink).toEqual({ + title: 'Dashboard 001', + description: 'Some descriptive text.', + label: 'My Dashboard', + }); + }); + + it('adds an error for missing dashboard', async () => { + const link: Link = { + id: '1', + type: DASHBOARD_LINK_TYPE, + order: 0, + destination: '404', + }; + const resolvedLink = await resolveLinkInfo(link); + expect(resolvedLink).toEqual({ + title: 'Error fetching dashboard', + description: 'Dashboard not found', + error: new Error('Dashboard not found'), + }); + }); +}); diff --git a/src/plugins/links/public/lib/resolve_links.ts b/src/plugins/links/public/lib/resolve_links.ts new file mode 100644 index 00000000000000..c3ae15ef5393bd --- /dev/null +++ b/src/plugins/links/public/lib/resolve_links.ts @@ -0,0 +1,73 @@ +/* + * 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 { ResolvedLink } from '../types'; +import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE, Link } from '../../common/content_management'; +import { validateUrl } from '../components/external_link/external_link_tools'; +import { fetchDashboard } from '../components/dashboard_link/dashboard_link_tools'; +import { DashboardLinkStrings } from '../components/dashboard_link/dashboard_link_strings'; +import { LinksStrings } from '../components/links_strings'; + +export const getOrderedLinkList = (links: ResolvedLink[]): ResolvedLink[] => { + return [...links].sort((linkA, linkB) => { + return linkA.order - linkB.order; + }); +}; + +/** + * 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: ResolvedLink[]) => { + return getOrderedLinkList(links); + }, + (links: ResolvedLink[]) => { + return links; + } +); + +export async function resolveLinks(links: Link[] = []) { + const resolvedLinkInfos = await Promise.all( + links.map(async (link) => { + return { ...link, ...(await resolveLinkInfo(link)) }; + }) + ); + return getOrderedLinkList(resolvedLinkInfos); +} + +export async function resolveLinkInfo( + link: Link +): Promise<{ title: string; label?: string; description?: string; error?: Error }> { + if (link.type === EXTERNAL_LINK_TYPE) { + const info = { title: link.label ?? link.destination }; + const { valid, message } = validateUrl(link.destination); + if (valid) { + return info; + } + return { ...info, error: new Error(message) }; + } + if (link.type === DASHBOARD_LINK_TYPE) { + if (!link.destination) return { title: '' }; + try { + const { + attributes: { title, description }, + } = await fetchDashboard(link.destination); + return { label: link.label, title, description }; + } catch (error) { + return { + title: DashboardLinkStrings.getDashboardErrorLabel(), + description: error.message, + error, + }; + } + } + throw new Error(LinksStrings.embeddable.getUnsupportedLinkTypeError()); +} diff --git a/src/plugins/links/public/lib/serialize_attributes.ts b/src/plugins/links/public/lib/serialize_attributes.ts new file mode 100644 index 00000000000000..d9e8d1de148bbb --- /dev/null +++ b/src/plugins/links/public/lib/serialize_attributes.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 { Link } from '../../common/content_management'; +import { extractReferences } from '../../common/persistable_state'; +import { LinksRuntimeState } from '../types'; + +export const serializeLinksAttributes = ( + state: LinksRuntimeState, + shouldExtractReferences: boolean = true +) => { + const linksToSave: Link[] | undefined = state.links?.map( + ({ title, description, error, ...linkToSave }) => linkToSave + ); + const attributes = { + title: state.defaultPanelTitle, + description: state.defaultPanelDescription, + layout: state.layout, + links: linksToSave, + }; + + const serializedState = shouldExtractReferences + ? extractReferences({ attributes }) + : { attributes, references: [] }; + + return serializedState; +}; diff --git a/src/plugins/links/public/mocks.ts b/src/plugins/links/public/mocks.ts new file mode 100644 index 00000000000000..89f15ef9ea2059 --- /dev/null +++ b/src/plugins/links/public/mocks.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { presentationUtilPluginMock } from '@kbn/presentation-util-plugin/public/mocks'; +import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; +import { BehaviorSubject } from 'rxjs'; +import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks'; +import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import { Reference } from '@kbn/content-management-utils'; +import { setKibanaServices } from './services/kibana_services'; +import { LinksParentApi, LinksSerializedState } from './types'; + +export const setStubKibanaServices = () => { + const mockCore = coreMock.createStart(); + + const core = { + ...mockCore, + application: { + ...mockCore.application, + capabilities: { + ...mockCore.application.capabilities, + visualize: { + save: true, + }, + }, + }, + }; + + setKibanaServices(core, { + dashboard: dashboardPluginMock.createStartContract(), + embeddable: embeddablePluginMock.createStartContract(), + contentManagement: contentManagementMock.createStartContract(), + presentationUtil: presentationUtilPluginMock.createStartContract(core), + uiActions: uiActionsPluginMock.createStartContract(), + }); +}; + +export const getMockLinksParentApi = ( + serializedState: LinksSerializedState, + references?: Reference[] +): LinksParentApi => ({ + ...getMockPresentationContainer(), + type: 'dashboard', + filters$: new BehaviorSubject(undefined), + query$: new BehaviorSubject(undefined), + timeRange$: new BehaviorSubject({ + from: 'now-15m', + to: 'now', + }), + timeslice$: new BehaviorSubject<[number, number] | undefined>(undefined), + savedObjectId: new BehaviorSubject('999'), + hidePanelTitle: new BehaviorSubject(false), + panelTitle: new BehaviorSubject('My Dashboard'), + panelDescription: new BehaviorSubject(''), + getSerializedStateForChild: () => ({ rawState: serializedState, references }), +}); diff --git a/src/plugins/links/public/mocks.tsx b/src/plugins/links/public/mocks.tsx deleted file mode 100644 index 6a27185c9b09a3..00000000000000 --- a/src/plugins/links/public/mocks.tsx +++ /dev/null @@ -1,25 +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 { 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 = () => { - const core = coreMock.createStart(); - - setKibanaServices(core, { - dashboard: dashboardPluginMock.createStartContract(), - embeddable: embeddablePluginMock.createStartContract(), - contentManagement: contentManagementMock.createStartContract(), - presentationUtil: presentationUtilPluginMock.createStartContract(core), - }); -}; diff --git a/src/plugins/links/public/plugin.ts b/src/plugins/links/public/plugin.ts index 32788a2a283c11..f5ac0dd5f1d20a 100644 --- a/src/plugins/links/public/plugin.ts +++ b/src/plugins/links/public/plugin.ts @@ -11,21 +11,28 @@ import { ContentManagementPublicStart, } from '@kbn/content-management-plugin/public'; import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; -import { DashboardStart } from '@kbn/dashboard-plugin/public'; -import type { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { + DashboardStart, + DASHBOARD_GRID_COLUMN_COUNT, + PanelPlacementStrategy, +} from '@kbn/dashboard-plugin/public'; import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; import { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import { VisualizationsSetup } from '@kbn/visualizations-plugin/public'; +import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin'; +import { LinksSerializedState } from './types'; import { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from '../common'; import { LinksCrudTypes } from '../common/content_management'; import { LinksStrings } from './components/links_strings'; import { getLinksClient } from './content_management/links_content_management_client'; -import { LinksFactoryDefinition } from './embeddable'; -import { LinksByReferenceInput } from './embeddable/types'; -import { setKibanaServices } from './services/kibana_services'; - +import { setKibanaServices, untilPluginStartServicesReady } from './services/kibana_services'; +import { registerCreateLinksPanelAction } from './actions/create_links_panel_action'; +import { + deserializeLinksSavedObject, + linksSerializeStateIsByReference, +} from './lib/deserialize_from_library'; export interface LinksSetupDependencies { embeddable: EmbeddableSetup; visualizations: VisualizationsSetup; @@ -37,6 +44,7 @@ export interface LinksStartDependencies { dashboard: DashboardStart; presentationUtil: PresentationUtilPluginStart; contentManagement: ContentManagementPublicStart; + uiActions: UiActionsPublicStart; usageCollection?: UsageCollectionStart; } @@ -47,10 +55,6 @@ export class LinksPlugin public setup(core: CoreSetup, plugins: LinksSetupDependencies) { core.getStartServices().then(([_, deps]) => { - const linksFactory = new LinksFactoryDefinition(); - - plugins.embeddable.registerEmbeddableFactory(CONTENT_ID, linksFactory); - plugins.contentManagement.registry.register({ id: CONTENT_ID, version: { @@ -59,20 +63,23 @@ export class LinksPlugin name: APP_NAME, }); - const getExplicitInput = async ({ - savedObjectId, - parent, - }: { - savedObjectId?: string; - parent?: DashboardContainer; - }) => { - try { - await linksFactory.getExplicitInput({ savedObjectId } as LinksByReferenceInput, parent); - } catch { - // swallow any errors - this just means that the user cancelled editing - } - return; - }; + plugins.embeddable.registerReactEmbeddableSavedObject({ + onAdd: (container, savedObject) => { + container.addNewPanel({ + panelType: CONTENT_ID, + initialState: { savedObjectId: savedObject.id }, + }); + }, + embeddableType: CONTENT_ID, + savedObjectType: CONTENT_ID, + savedObjectName: APP_NAME, + getIconForSavedObject: () => APP_ICON, + }); + + plugins.embeddable.registerReactEmbeddableFactory(CONTENT_ID, async () => { + const { getLinksEmbeddableFactory } = await import('./embeddable/links_embeddable'); + return getLinksEmbeddableFactory(); + }); plugins.visualizations.registerAlias({ disableCreate: true, // do not allow creation through visualization listing page @@ -93,7 +100,13 @@ export class LinksPlugin return { id, title, - editor: { onEdit: (savedObjectId: string) => getExplicitInput({ savedObjectId }) }, + editor: { + onEdit: async (savedObjectId: string) => { + const { openEditorFlyout } = await import('./editor/open_editor_flyout'); + const initialState = await deserializeLinksSavedObject({ savedObjectId }); + await openEditorFlyout({ initialState }); + }, + }, description, updatedAt, icon: APP_ICON, @@ -110,6 +123,24 @@ export class LinksPlugin public start(core: CoreStart, plugins: LinksStartDependencies) { setKibanaServices(core, plugins); + untilPluginStartServicesReady().then(() => { + registerCreateLinksPanelAction(); + + plugins.dashboard.registerDashboardPanelPlacementSetting( + CONTENT_ID, + async (serializedState?: LinksSerializedState) => { + if (!serializedState) return {}; + const { links, layout } = linksSerializeStateIsByReference(serializedState) + ? await deserializeLinksSavedObject(serializedState) + : serializedState.attributes; + const isHorizontal = layout === 'horizontal'; + const width = isHorizontal ? DASHBOARD_GRID_COLUMN_COUNT : 8; + const height = isHorizontal ? 4 : (links?.length ?? 1 * 3) + 4; + return { width, height, strategy: PanelPlacementStrategy.placeAtTop }; + } + ); + }); + return {}; } diff --git a/src/plugins/links/public/services/attribute_service.ts b/src/plugins/links/public/services/attribute_service.ts deleted file mode 100644 index bde2ab27c1d15b..00000000000000 --- a/src/plugins/links/public/services/attribute_service.ts +++ /dev/null @@ -1,97 +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 { 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 { LinksAttributes } from '../../common/content_management'; -import { extractReferences, injectReferences } from '../../common/persistable_state'; -import { LinksByReferenceInput, LinksByValueInput } from '../embeddable/types'; -import { embeddableService } from './kibana_services'; -import { checkForDuplicateTitle, linksClient } from '../content_management'; -import { CONTENT_ID } from '../../common'; - -export type LinksDocument = LinksAttributes & { - references?: Reference[]; -}; - -export interface LinksUnwrapMetaInfo { - sharingSavedObjectProps?: SharingSavedObjectProps; -} - -export type LinksAttributeService = AttributeService< - LinksDocument, - LinksByValueInput, - LinksByReferenceInput, - LinksUnwrapMetaInfo ->; - -let linksAttributeService: LinksAttributeService | null = null; -export function getLinksAttributeService(): LinksAttributeService { - if (linksAttributeService) return linksAttributeService; - - linksAttributeService = embeddableService.getAttributeService< - LinksDocument, - LinksByValueInput, - LinksByReferenceInput, - LinksUnwrapMetaInfo - >(CONTENT_ID, { - saveMethod: async (attributes: LinksDocument, savedObjectId?: string) => { - const { attributes: updatedAttributes, references } = extractReferences({ - attributes, - references: attributes.references, - }); - const { - item: { id }, - } = await (savedObjectId - ? linksClient.update({ - id: savedObjectId, - data: updatedAttributes, - options: { references }, - }) - : linksClient.create({ data: updatedAttributes, options: { references } })); - return { id }; - }, - unwrapMethod: async ( - savedObjectId: string - ): Promise<{ - attributes: LinksDocument; - metaInfo: LinksUnwrapMetaInfo; - }> => { - const { - item: savedObject, - meta: { outcome, aliasPurpose, aliasTargetId }, - } = await linksClient.get(savedObjectId); - if (savedObject.error) throw savedObject.error; - - const { attributes } = injectReferences(savedObject); - 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 linksAttributeService; -} diff --git a/src/plugins/links/public/services/kibana_services.ts b/src/plugins/links/public/services/kibana_services.ts index 7536c12262792d..f25ecdd4c3f162 100644 --- a/src/plugins/links/public/services/kibana_services.ts +++ b/src/plugins/links/public/services/kibana_services.ts @@ -14,6 +14,7 @@ import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; +import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin'; import { CONTENT_ID } from '../../common'; import { LinksStartDependencies } from '../plugin'; @@ -22,6 +23,7 @@ export let dashboardServices: DashboardStart; export let embeddableService: EmbeddableStart; export let presentationUtil: PresentationUtilPluginStart; export let contentManagement: ContentManagementPublicStart; +export let uiActions: UiActionsPublicStart; export let trackUiMetric: ( type: string, eventNames: string | string[], @@ -48,6 +50,7 @@ export const setKibanaServices = (kibanaCore: CoreStart, deps: LinksStartDepende embeddableService = deps.embeddable; presentationUtil = deps.presentationUtil; contentManagement = deps.contentManagement; + uiActions = deps.uiActions; if (deps.usageCollection) trackUiMetric = deps.usageCollection.reportUiCounter.bind(deps.usageCollection, CONTENT_ID); diff --git a/src/plugins/links/public/types.ts b/src/plugins/links/public/types.ts new file mode 100644 index 00000000000000..e88c6e5d51c84e --- /dev/null +++ b/src/plugins/links/public/types.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { + HasEditCapabilities, + HasInPlaceLibraryTransforms, + HasType, + PublishesPanelDescription, + PublishesPanelTitle, + PublishesSavedObjectId, + PublishesUnifiedSearch, + SerializedTitles, +} from '@kbn/presentation-publishing'; +import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; +import { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public/plugin'; +import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers'; +import { LocatorPublic } from '@kbn/share-plugin/common'; +import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '@kbn/dashboard-plugin/public'; +import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; + +import { CONTENT_ID } from '../common'; +import { Link, LinksAttributes, LinksLayoutType } from '../common/content_management'; + +export type LinksParentApi = PresentationContainer & + HasType & + HasSerializedChildState & + PublishesSavedObjectId & + PublishesPanelTitle & + PublishesPanelDescription & + PublishesUnifiedSearch & { + locator?: Pick, 'navigate' | 'getRedirectUrl'>; + }; + +export type LinksApi = HasType & + DefaultEmbeddableApi & + HasEditCapabilities & + HasInPlaceLibraryTransforms; + +export interface LinksByReferenceSerializedState { + savedObjectId: string; +} + +export interface LinksByValueSerializedState { + attributes: LinksAttributes; +} + +export type LinksSerializedState = SerializedTitles & + Partial & + (LinksByReferenceSerializedState | LinksByValueSerializedState); + +export interface LinksRuntimeState + extends Partial, + SerializedTitles { + error?: Error; + links?: ResolvedLink[]; + layout?: LinksLayoutType; + defaultPanelTitle?: string; + defaultPanelDescription?: string; +} + +export type ResolvedLink = Link & { + title: string; + label?: string; + description?: string; + error?: Error; +}; + +export interface DashboardItem { + id: string; + attributes: DashboardAttributes; +} diff --git a/src/plugins/links/tsconfig.json b/src/plugins/links/tsconfig.json index e0a5a1b1d42217..321809cb507d0c 100644 --- a/src/plugins/links/tsconfig.json +++ b/src/plugins/links/tsconfig.json @@ -19,9 +19,8 @@ "@kbn/saved-objects-plugin", "@kbn/core-saved-objects-server", "@kbn/saved-objects-plugin", + "@kbn/ui-actions-plugin", "@kbn/ui-actions-enhanced-plugin", - "@kbn/kibana-utils-plugin", - "@kbn/utility-types", "@kbn/ui-actions-plugin", "@kbn/logging", "@kbn/core-plugins-server", @@ -32,7 +31,11 @@ "@kbn/core-mount-utils-browser", "@kbn/presentation-containers", "@kbn/presentation-publishing", - "@kbn/react-kibana-context-render" + "@kbn/react-kibana-context-render", + "@kbn/presentation-panel-plugin", + "@kbn/embeddable-enhanced-plugin", + "@kbn/share-plugin", + "@kbn/es-query", ], "exclude": ["target/**/*"] } 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 97e78ca2fb0a4f..da67064602dad6 100644 --- a/test/functional/apps/dashboard_elements/links/links_create_edit.ts +++ b/test/functional/apps/dashboard_elements/links/links_create_edit.ts @@ -54,7 +54,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('can not add an external link that violates externalLinks.policy', async () => { await dashboardAddPanel.clickEditorMenuButton(); - await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + await dashboardAddPanel.clickAddNewPanelFromUIActionLink('Links'); await dashboardLinks.setExternalUrlInput('https://danger.example.com'); expect(await testSubjects.exists('links--linkDestination--error')).to.be(true); @@ -64,7 +64,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('can create a new by-reference links panel', async () => { await dashboardAddPanel.clickEditorMenuButton(); - await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + await dashboardAddPanel.clickAddNewPanelFromUIActionLink('Links'); await createSomeLinks(); await dashboardLinks.toggleSaveByReference(true); @@ -75,8 +75,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('confirmSaveSavedObjectButton'); await common.waitForSaveModalToClose(); await testSubjects.exists('addObjectToDashboardSuccess'); + await testSubjects.existOrFail('links--component'); + await testSubjects.existOrFail('embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION'); - expect(await testSubjects.existOrFail('links--component')); expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(4); await dashboard.clickDiscardChanges(); }); @@ -84,14 +85,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('by-value links panel', async () => { it('can create a new by-value links panel', async () => { await dashboardAddPanel.clickEditorMenuButton(); - await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + await dashboardAddPanel.clickAddNewPanelFromUIActionLink('Links'); await dashboardLinks.setLayout('horizontal'); await createSomeLinks(); await dashboardLinks.toggleSaveByReference(false); await dashboardLinks.clickPanelEditorSaveButton(); await testSubjects.exists('addObjectToDashboardSuccess'); + await testSubjects.existOrFail('links--component'); + await testSubjects.missingOrFail( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION' + ); - expect(await testSubjects.existOrFail('links--component')); expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(4); }); @@ -101,10 +105,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboard.clickUnsavedChangesContinueEditing(DASHBOARD_NAME); await dashboard.waitForRenderComplete(); - await dashboardPanelActions.legacySaveToLibrary('Some more links'); + await dashboardPanelActions.saveToLibrary('Some more links'); await testSubjects.existOrFail('addPanelToLibrarySuccess'); }); + it('can unlink a panel from the library', async () => { + const panel = await testSubjects.find('embeddablePanelHeading-Somemorelinks'); + await dashboardPanelActions.unlinkFromLibrary(panel); + await testSubjects.existOrFail('unlinkPanelSuccess'); + }); + after(async () => { await dashboard.clickDiscardChanges(); }); diff --git a/x-pack/test/accessibility/apps/group1/dashboard_links.ts b/x-pack/test/accessibility/apps/group1/dashboard_links.ts index 3ec6df1b880ca4..766de0e3d80f31 100644 --- a/x-pack/test/accessibility/apps/group1/dashboard_links.ts +++ b/x-pack/test/accessibility/apps/group1/dashboard_links.ts @@ -49,7 +49,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('Empty links editor flyout', async () => { await dashboardAddPanel.clickEditorMenuButton(); - await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + await dashboardAddPanel.clickAddNewPanelFromUIActionLink('Links'); await a11y.testAppSnapshot(); });