From 577134c2013a2f1caa5ea4b616f36dabd5d22e37 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Thu, 10 Aug 2023 13:42:10 -0400 Subject: [PATCH] [Navigation embeddable] Add content management (#160896) Fixes https://github.com/elastic/kibana/issues/154362 ## Summary Adds content management to navigation embeddable feature branch. Allows Links panels to be by-value or by-reference on a Dashboard. The UX for users to choose to save by-value or by-reference remains to be finalized and is out of scope for this PR. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Hannah Mudge --- .../src/constants.ts | 1 + .../src/kibana_migrator_utils.fixtures.ts | 17 +++ .../current_mappings.json | 17 +++ .../group2/check_registered_types.test.ts | 1 + .../group5/dot_kibana_split.test.ts | 1 + .../replace_panel_flyout.tsx | 30 +--- .../embeddable/api/panel_management.ts | 49 ++----- .../public/lib/actions/edit_panel_action.ts | 8 +- .../public/lib/containers/container.ts | 38 ++++-- .../public/lib/containers/i_container.ts | 5 +- .../navigation_embeddable/common/constants.ts | 19 +++ .../common/content_management/cm_services.ts | 21 +++ .../common/content_management/index.ts | 23 ++++ .../common/content_management/latest.ts | 9 ++ .../content_management/v1/cm_services.ts | 108 +++++++++++++++ .../common/content_management/v1/constants.ts | 17 +++ .../common/content_management/v1/index.ts | 17 +++ .../common/content_management/v1/types.ts | 46 +++++++ .../navigation_embeddable/common/index.ts | 9 ++ .../navigation_embeddable/common/types.ts | 19 +++ .../navigation_embeddable/kibana.jsonc | 17 ++- .../dashboard_link_component.tsx | 7 +- .../external_link/external_link_component.tsx | 7 +- .../navigation_embeddable_component.tsx | 9 +- .../navigation_embeddable_link_editor.tsx | 8 +- .../navigation_embeddable_panel_editor.tsx | 129 ++++++++++++------ ...avigation_embeddable_panel_editor_link.tsx | 7 +- .../navigation_embeddable_strings.ts | 39 +++++- .../public/components/tooltip_wrapper.tsx | 35 +++++ .../duplicate_title_check.ts | 57 ++++++++ .../public/content_management/index.ts | 10 ++ ...embeddable_content_management_client.ts.ts | 83 +++++++++++ .../content_management/save_to_library.tsx | 82 +++++++++++ .../navigation_embeddable_editor_tools.tsx | 18 +-- .../public/editor/open_editor_flyout.tsx | 48 ++++++- .../public/editor/open_link_editor_flyout.tsx | 2 +- .../public/embeddable/index.ts | 5 +- .../embeddable/navigation_embeddable.tsx | 90 ++++++++++-- .../navigation_embeddable_factory.ts | 80 +++++++---- .../public/embeddable/types.ts | 60 ++++---- .../navigation_embeddable/public/index.ts | 6 +- .../navigation_embeddable/public/plugin.ts | 27 +++- .../public/services/attribute_service.ts | 93 +++++++++++++ .../public/services/kibana_services.ts | 6 + .../server/content_management/index.ts | 9 ++ .../navigation_embeddable_storage.ts | 23 ++++ .../navigation_embeddable/server/index.ts | 11 ++ .../navigation_embeddable/server/plugin.ts | 43 ++++++ .../server/saved_objects/index.ts | 9 ++ .../saved_objects/navigation_embeddable.ts | 40 ++++++ .../navigation_embeddable/tsconfig.json | 8 ++ 51 files changed, 1275 insertions(+), 248 deletions(-) create mode 100644 src/plugins/navigation_embeddable/common/constants.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/cm_services.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/index.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/latest.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/constants.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/index.ts create mode 100644 src/plugins/navigation_embeddable/common/content_management/v1/types.ts create mode 100644 src/plugins/navigation_embeddable/common/index.ts create mode 100644 src/plugins/navigation_embeddable/common/types.ts create mode 100644 src/plugins/navigation_embeddable/public/components/tooltip_wrapper.tsx create mode 100644 src/plugins/navigation_embeddable/public/content_management/duplicate_title_check.ts create mode 100644 src/plugins/navigation_embeddable/public/content_management/index.ts create mode 100644 src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts create mode 100644 src/plugins/navigation_embeddable/public/content_management/save_to_library.tsx create mode 100644 src/plugins/navigation_embeddable/public/services/attribute_service.ts create mode 100644 src/plugins/navigation_embeddable/server/content_management/index.ts create mode 100644 src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts create mode 100644 src/plugins/navigation_embeddable/server/index.ts create mode 100644 src/plugins/navigation_embeddable/server/plugin.ts create mode 100644 src/plugins/navigation_embeddable/server/saved_objects/index.ts create mode 100644 src/plugins/navigation_embeddable/server/saved_objects/navigation_embeddable.ts diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts index c4a3018fdfb3..9347ce4fd7f1 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts @@ -77,6 +77,7 @@ export const DEFAULT_INDEX_TYPES_MAP: IndexTypesMap = { 'ml-module', 'ml-trained-model', 'monitoring-telemetry', + 'navigation_embeddable', 'osquery-manager-usage-metric', 'osquery-pack', 'osquery-pack-asset', diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts index 9100f489bef4..94780b9abf80 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator_utils.fixtures.ts @@ -1504,6 +1504,23 @@ export const INDEX_MAP_BEFORE_SPLIT: IndexMap = { }, }, }, + navigation_embeddable: { + properties: { + id: { + type: 'text', + }, + title: { + type: 'text', + }, + description: { + type: 'text', + }, + links: { + dynamic: false, + properties: {}, + }, + }, + }, 'cases-comments': { dynamic: false, properties: { diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index cb78e060d6ed..169221d659e5 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1054,6 +1054,23 @@ } } }, + "navigation_embeddable": { + "properties": { + "id": { + "type": "text" + }, + "title": { + "type": "text" + }, + "description": { + "type": "text" + }, + "links": { + "dynamic": false, + "properties": {} + } + } + }, "lens": { "properties": { "title": { diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index c1cfca07017b..bc6be0a11b2b 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -120,6 +120,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ml-module": "2225cbb4bd508ea5f69db4b848be9d8a74b60198", "ml-trained-model": "482195cefd6b04920e539d34d7356d22cb68e4f3", "monitoring-telemetry": "5d91bf75787d9d4dd2fae954d0b3f76d33d2e559", + "navigation_embeddable": "de71a127ed325261ca6bc926d93c4cd676d17a05", "observability-onboarding-state": "55b112d6a33fedb7c1e4fec4da768d2bcc5fadc2", "osquery-manager-usage-metric": "983bcbc3b7dda0aad29b20907db233abba709bcc", "osquery-pack": "6ab4358ca4304a12dcfc1777c8135b75cffb4397", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index 30888521d651..99b16aec7107 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -240,6 +240,7 @@ describe('split .kibana index into multiple system indices', () => { "ml-module", "ml-trained-model", "monitoring-telemetry", + "navigation_embeddable", "observability-onboarding-state", "osquery-manager-usage-metric", "osquery-pack", diff --git a/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx index 14067f0b6aa6..6f93b08a2708 100644 --- a/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx @@ -18,7 +18,6 @@ import { } from '@kbn/embeddable-plugin/public'; import { Toast } from '@kbn/core/public'; -import { DashboardPanelState } from '../../common'; import { pluginServices } from '../services/plugin_services'; import { dashboardReplacePanelActionStrings } from './_dashboard_actions_strings'; import { DashboardContainer } from '../dashboard_container'; @@ -58,30 +57,15 @@ export class ReplacePanelFlyout extends React.Component { public onReplacePanel = async (savedObjectId: string, type: string, name: string) => { const { panelToRemove, container } = this.props; - const { w, h, x, y } = (container.getInput().panels[panelToRemove.id] as DashboardPanelState) - .gridData; - const { id } = await container.addNewEmbeddable(type, { - savedObjectId, - }); - - const { [panelToRemove.id]: omit, ...panels } = container.getInput().panels; - - container.updateInput({ - panels: { - ...panels, - [id]: { - ...panels[id], - gridData: { - ...(panels[id] as DashboardPanelState).gridData, - w, - h, - x, - y, - }, - } as DashboardPanelState, + const id = await container.replaceEmbeddable( + panelToRemove.id, + { + savedObjectId, }, - }); + type, + true + ); (container as DashboardContainer).setHighlightPanelId(id); this.showToast(name); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts index 7b02001a93c6..a052a8fef103 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts @@ -47,46 +47,15 @@ export async function replacePanel( newPanelState: Partial, generateNewId?: boolean ): Promise { - let panels; - let panelId; - - if (generateNewId) { - // replace panel can be called with generateNewId in order to totally destroy and recreate the embeddable - panelId = uuidv4(); - panels = { ...this.input.panels }; - delete panels[previousPanelState.explicitInput.id]; - panels[panelId] = { - ...previousPanelState, - ...newPanelState, - gridData: { - ...previousPanelState.gridData, - i: panelId, - }, - explicitInput: { - ...newPanelState.explicitInput, - id: panelId, - }, - }; - } else { - // Because the embeddable type can change, we have to operate at the container level here - panelId = previousPanelState.explicitInput.id; - panels = { - ...this.input.panels, - [panelId]: { - ...previousPanelState, - ...newPanelState, - gridData: { - ...previousPanelState.gridData, - }, - explicitInput: { - ...newPanelState.explicitInput, - id: panelId, - }, - }, - }; - } - - await this.updateInput({ panels }); + const panelId = await this.replaceEmbeddable( + previousPanelState.explicitInput.id, + { + ...newPanelState.explicitInput, + id: previousPanelState.explicitInput.id, + }, + newPanelState.type, + generateNewId + ); return panelId; } diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index 98e541fd08e6..2076c8d6f1e7 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -92,7 +92,13 @@ export class EditPanelAction implements Action { } const oldExplicitInput = embeddable.getExplicitInput(); - const newExplicitInput = await factory.getExplicitInput(oldExplicitInput, embeddable.parent); + let newExplicitInput: Awaited>; + try { + newExplicitInput = await factory.getExplicitInput(oldExplicitInput, embeddable.parent); + } catch (e) { + // error likely means user canceled editing + return; + } embeddable.parent?.replaceEmbeddable(embeddable.id, newExplicitInput); return; } diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 2f29410bc3e5..df007a93483e 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -176,7 +176,12 @@ export abstract class Container< EEI extends EmbeddableInput = EmbeddableInput, EEO extends EmbeddableOutput = EmbeddableOutput, E extends IEmbeddable = IEmbeddable - >(id: string, newExplicitInput: Partial, newType?: string) { + >( + id: string, + newExplicitInput: Partial, + newType?: string, + generateNewId?: boolean + ): Promise { if (!this.input.panels[id]) { throw new PanelNotFoundError(); } @@ -186,21 +191,28 @@ export abstract class Container< if (!factory) { throw new EmbeddableFactoryNotFoundError(newType); } - this.updateInput({ - panels: { - ...this.input.panels, - [id]: { - ...this.input.panels[id], - explicitInput: { ...newExplicitInput, id }, - type: newType, - }, - }, - } as Partial); - } else { - this.updateInputForChild(id, newExplicitInput); } + const panels = { ...this.input.panels }; + const oldPanel = panels[id]; + + if (generateNewId) { + delete panels[id]; + id = uuidv4(); + } + this.updateInput({ + panels: { + ...panels, + [id]: { + ...oldPanel, + explicitInput: { ...newExplicitInput, id }, + type: newType ?? oldPanel.type, + }, + }, + } as Partial); + await this.untilEmbeddableLoaded(id); + return id; } public removeEmbeddable(embeddableId: string) { diff --git a/src/plugins/embeddable/public/lib/containers/i_container.ts b/src/plugins/embeddable/public/lib/containers/i_container.ts index 5539f854b24d..34e7cc0593e6 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -106,6 +106,7 @@ export interface IContainer< >( id: string, newExplicitInput: Partial, - newType?: string - ): void; + newType?: string, + generateNewId?: boolean + ): Promise; } diff --git a/src/plugins/navigation_embeddable/common/constants.ts b/src/plugins/navigation_embeddable/common/constants.ts new file mode 100644 index 000000000000..9731275e04f1 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/constants.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const LATEST_VERSION = 1; + +export const CONTENT_ID = 'navigation_embeddable'; + +export const APP_ICON = 'link'; + +export const APP_NAME = i18n.translate('navigationEmbeddable.visTypeAlias.title', { + defaultMessage: 'Links', +}); diff --git a/src/plugins/navigation_embeddable/common/content_management/cm_services.ts b/src/plugins/navigation_embeddable/common/content_management/cm_services.ts new file mode 100644 index 000000000000..fa050138b35f --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/cm_services.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ContentManagementServicesDefinition as ServicesDefinition, + Version, +} from '@kbn/object-versioning'; + +// We export the versioned service definition from this file and not the barrel to avoid adding +// the schemas in the "public" js bundle + +import { serviceDefinition as v1 } from './v1/cm_services'; + +export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = { + 1: v1, +}; diff --git a/src/plugins/navigation_embeddable/common/content_management/index.ts b/src/plugins/navigation_embeddable/common/content_management/index.ts new file mode 100644 index 000000000000..282ba879c17f --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { LATEST_VERSION, CONTENT_ID } from '../constants'; + +export type { NavigationEmbeddableContentType } from '../types'; + +export type { + NavigationEmbeddableCrudTypes, + NavigationEmbeddableAttributes, + NavigationEmbeddableItem, + NavigationLinkType, + NavigationEmbeddableLink, +} from './latest'; + +export { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from './latest'; + +export * as NavigationEmbeddableV1 from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/latest.ts b/src/plugins/navigation_embeddable/common/content_management/latest.ts new file mode 100644 index 000000000000..e9c79f0f50f9 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/latest.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './v1'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts new file mode 100644 index 000000000000..5494a193ba7b --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/v1/cm_services.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning'; +import { + savedObjectSchema, + objectTypeToGetResultSchema, + createOptionsSchemas, + updateOptionsSchema, + createResultSchema, +} from '@kbn/content-management-utils'; +import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '.'; + +const navigationEmbeddableLinkSchema = schema.object({ + id: schema.string(), + type: schema.oneOf([schema.literal(DASHBOARD_LINK_TYPE), schema.literal(EXTERNAL_LINK_TYPE)]), + destination: schema.string(), + label: schema.maybe(schema.string()), + order: schema.number(), +}); + +const navigationEmbeddableAttributesSchema = schema.object( + { + title: schema.string(), + description: schema.maybe(schema.string()), + links: schema.maybe(schema.arrayOf(navigationEmbeddableLinkSchema)), + }, + { unknowns: 'forbid' } +); + +const navigationEmbeddableSavedObjectSchema = savedObjectSchema( + navigationEmbeddableAttributesSchema +); + +const searchOptionsSchema = schema.maybe( + schema.object( + { + onlyTitle: schema.maybe(schema.boolean()), + }, + { unknowns: 'forbid' } + ) +); + +const navigationEmbeddableCreateOptionsSchema = schema.object({ + references: schema.maybe(createOptionsSchemas.references), + overwrite: createOptionsSchemas.overwrite, +}); + +const navigationEmbeddableUpdateOptionsSchema = schema.object({ + references: updateOptionsSchema.references, +}); + +// Content management service definition. +// We need it for BWC support between different versions of the content +export const serviceDefinition: ServicesDefinition = { + get: { + out: { + result: { + schema: objectTypeToGetResultSchema(navigationEmbeddableSavedObjectSchema), + }, + }, + }, + create: { + in: { + options: { + schema: navigationEmbeddableCreateOptionsSchema, + }, + data: { + schema: navigationEmbeddableAttributesSchema, + }, + }, + out: { + result: { + schema: createResultSchema(navigationEmbeddableSavedObjectSchema), + }, + }, + }, + update: { + in: { + options: { + schema: navigationEmbeddableUpdateOptionsSchema, // same schema as "create" + }, + data: { + schema: navigationEmbeddableAttributesSchema, + }, + }, + }, + search: { + in: { + options: { + schema: searchOptionsSchema, + }, + }, + }, + mSearch: { + out: { + result: { + schema: navigationEmbeddableSavedObjectSchema, + }, + }, + }, +}; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts b/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts new file mode 100644 index 000000000000..00f40932638f --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/v1/constants.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Dashboard to dashboard links + */ +export const DASHBOARD_LINK_TYPE = 'dashboardLink'; + +/** + * External URL links + */ +export const EXTERNAL_LINK_TYPE = 'externalLink'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/index.ts b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts new file mode 100644 index 000000000000..bedc5a6ff2f0 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/v1/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { NavigationEmbeddableCrudTypes } from './types'; +export type { + NavigationEmbeddableCrudTypes, + NavigationEmbeddableAttributes, + NavigationEmbeddableLink, + NavigationLinkType, +} from './types'; +export type NavigationEmbeddableItem = NavigationEmbeddableCrudTypes['Item']; +export { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from './constants'; diff --git a/src/plugins/navigation_embeddable/common/content_management/v1/types.ts b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts new file mode 100644 index 000000000000..0d1a87a17d14 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/content_management/v1/types.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ContentManagementCrudTypes, + SavedObjectCreateOptions, + SavedObjectUpdateOptions, +} from '@kbn/content-management-utils'; +import { NavigationEmbeddableContentType } from '../../types'; +import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from './constants'; + +export type NavigationEmbeddableCrudTypes = ContentManagementCrudTypes< + NavigationEmbeddableContentType, + NavigationEmbeddableAttributes, + Pick, + Pick, + { + /** Flag to indicate to only search the text on the "title" field */ + onlyTitle?: boolean; + } +>; + +/** + * Navigation embeddable explicit input + */ +export type NavigationLinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; + +export interface NavigationEmbeddableLink { + id: string; + type: NavigationLinkType; + destination: string; + label?: string; + order: number; +} + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type NavigationEmbeddableAttributes = { + title: string; + description?: string; + links?: NavigationEmbeddableLink[]; +}; diff --git a/src/plugins/navigation_embeddable/common/index.ts b/src/plugins/navigation_embeddable/common/index.ts new file mode 100644 index 000000000000..9cb4fc42124a --- /dev/null +++ b/src/plugins/navigation_embeddable/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from './constants'; diff --git a/src/plugins/navigation_embeddable/common/types.ts b/src/plugins/navigation_embeddable/common/types.ts new file mode 100644 index 000000000000..e03b4a4dd146 --- /dev/null +++ b/src/plugins/navigation_embeddable/common/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectsResolveResponse } from '@kbn/core-saved-objects-api-server'; + +export type NavigationEmbeddableContentType = 'navigation_embeddable'; + +// TODO does this type need to be versioned? +export interface SharingSavedObjectProps { + outcome: SavedObjectsResolveResponse['outcome']; + aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; + sourceId?: string; +} diff --git a/src/plugins/navigation_embeddable/kibana.jsonc b/src/plugins/navigation_embeddable/kibana.jsonc index 961aacc7641a..b74e4bbd6f33 100644 --- a/src/plugins/navigation_embeddable/kibana.jsonc +++ b/src/plugins/navigation_embeddable/kibana.jsonc @@ -1,14 +1,23 @@ { "type": "plugin", - "owner": "@elastic/kibana-presentation", "id": "@kbn/navigation-embeddable-plugin", + "owner": "@elastic/kibana-presentation", "description": "An embeddable for quickly navigating between dashboards.", "plugin": { "id": "navigationEmbeddable", - "server": false, + "server": true, "browser": true, - "requiredPlugins": ["dashboard", "embeddable", "kibanaReact", "presentationUtil"], + "requiredPlugins": [ + "contentManagement", + "dashboard", + "embeddable", + "kibanaReact", + "presentationUtil" + ], "optionalPlugins": ["triggersActionsUi"], - "requiredBundles": [] + "requiredBundles": [ + "savedObjects" + ] } } + diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx index db371c426ed4..60b88a740c14 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx @@ -12,13 +12,10 @@ import useAsync from 'react-use/lib/useAsync'; import { EuiButtonEmpty } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { - DASHBOARD_LINK_TYPE, - NavigationEmbeddableLink, - NavigationLinkInfo, -} from '../../embeddable/types'; +import { NavigationLinkInfo } from '../../embeddable/types'; import { fetchDashboard } from './dashboard_link_tools'; import { useNavigationEmbeddable } from '../../embeddable/navigation_embeddable'; +import { DASHBOARD_LINK_TYPE, NavigationEmbeddableLink } from '../../../common/content_management'; export const DashboardLinkComponent = ({ link }: { link: NavigationEmbeddableLink }) => { const navEmbeddable = useNavigationEmbeddable(); diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx index 90bf4066d4c2..7b940ac02735 100644 --- a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx @@ -9,11 +9,8 @@ import React from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; -import { - EXTERNAL_LINK_TYPE, - NavigationLinkInfo, - NavigationEmbeddableLink, -} from '../../embeddable/types'; +import { NavigationLinkInfo } from '../../embeddable/types'; +import { EXTERNAL_LINK_TYPE, NavigationEmbeddableLink } from '../../../common/content_management'; export const ExternalLinkComponent = ({ link }: { link: NavigationEmbeddableLink }) => { return ( diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx index b17be812accb..a45d6d802867 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx @@ -10,17 +10,22 @@ import React, { useMemo } from 'react'; import { EuiPanel } from '@elastic/eui'; -import { DASHBOARD_LINK_TYPE } from '../embeddable/types'; +import { DASHBOARD_LINK_TYPE } from '../../common/content_management'; import { useNavigationEmbeddable } from '../embeddable/navigation_embeddable'; import { ExternalLinkComponent } from './external_link/external_link_component'; import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; import { memoizedGetOrderedLinkList } from '../editor/navigation_embeddable_editor_tools'; +import { NavigationEmbeddableByValueInput } from '../embeddable/types'; export const NavigationEmbeddableComponent = () => { const navEmbeddable = useNavigationEmbeddable(); - const links = navEmbeddable.select((state) => state.explicitInput.links); + const links = navEmbeddable.select( + (state) => (state.explicitInput as NavigationEmbeddableByValueInput).attributes?.links + ); + const orderedLinks = useMemo(() => { + if (!links) return []; return memoizedGetOrderedLinkList(links); }, [links]); diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx index 1d5fa9876605..def291c63bca 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx @@ -28,14 +28,14 @@ import { } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { NavigationLinkInfo } from '../embeddable/types'; import { - NavigationLinkInfo, NavigationLinkType, EXTERNAL_LINK_TYPE, DASHBOARD_LINK_TYPE, NavigationEmbeddableLink, - DashboardItem, -} from '../embeddable/types'; +} from '../../common/content_management'; +import { DashboardItem } from '../embeddable/types'; import { NavEmbeddableStrings } from './navigation_embeddable_strings'; import { NavigationEmbeddableUnorderedLink } from '../editor/open_link_editor_flyout'; import { ExternalLinkDestinationPicker } from './external_link/external_link_destination_picker'; @@ -177,7 +177,7 @@ export const NavigationEmbeddableLinkEditor = ({ {/* TODO: As part of https://github.com/elastic/kibana/issues/154381, we should pull in the custom settings for each link type. Refer to `x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/collect_config_container.tsx` - for the dashboard drilldown settings, for example. + for the dashboard drilldown settings, for example. Open question: It probably makes sense to re-use these components so any changes made to the drilldown architecture trickle down to the navigation embeddable - this would require some refactoring, though. Is this a goal for MVP? diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx index f4c2ce6b5149..97a5a86a9d65 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx @@ -17,7 +17,6 @@ import { EuiPanel, EuiSpacer, EuiButton, - EuiToolTip, EuiFormRow, EuiFlexItem, EuiFlexGroup, @@ -30,55 +29,63 @@ import { EuiFlyoutHeader, EuiDragDropContext, euiDragDropReorder, + EuiToolTip, } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { coreServices } from '../services/kibana_services'; -import { - NavigationEmbeddableLink, - NavigationEmbeddableInput, - NavigationEmbeddableLinkList, -} from '../embeddable/types'; +import { NavigationEmbeddableLink } from '../../common/content_management'; import { NavEmbeddableStrings } from './navigation_embeddable_strings'; import { openLinkEditorFlyout } from '../editor/open_link_editor_flyout'; import { memoizedGetOrderedLinkList } from '../editor/navigation_embeddable_editor_tools'; import { NavigationEmbeddablePanelEditorLink } from './navigation_embeddable_panel_editor_link'; +import { TooltipWrapper } from './tooltip_wrapper'; import noLinksIllustrationDark from '../assets/empty_links_dark.svg'; import noLinksIllustrationLight from '../assets/empty_links_light.svg'; - import './navigation_embeddable.scss'; const NavigationEmbeddablePanelEditor = ({ - onSave, + onSaveToLibrary, + onAddToDashboard, onClose, - initialInput, + initialLinks, parentDashboard, + isByReference, }: { + onSaveToLibrary: (newLinks: NavigationEmbeddableLink[]) => Promise; + onAddToDashboard: (newLinks: NavigationEmbeddableLink[]) => void; onClose: () => void; + initialLinks?: NavigationEmbeddableLink[]; parentDashboard?: DashboardContainer; - initialInput: Partial; - onSave: (input: Partial) => void; + isByReference: boolean; }) => { const isDarkTheme = useObservable(coreServices.theme.theme$)?.darkMode; + const toasts = coreServices.notifications.toasts; const editLinkFlyoutRef: React.RefObject = useMemo(() => React.createRef(), []); const [orderedLinks, setOrderedLinks] = useState([]); + const [isSaving, setIsSaving] = useState(false); + + const isEditingExisting = initialLinks || isByReference; useEffect(() => { - const { links: initialLinks } = initialInput; if (!initialLinks) { setOrderedLinks([]); return; } setOrderedLinks(memoizedGetOrderedLinkList(initialLinks)); - }, [initialInput]); + }, [initialLinks]); const onDragEnd = useCallback( ({ source, destination }) => { if (source && destination) { - const newList = euiDragDropReorder(orderedLinks, source.index, destination.index); + const newList = euiDragDropReorder(orderedLinks, source.index, destination.index).map( + (link, i) => { + return { ...link, order: i }; + } + ); setOrderedLinks(newList); } }, @@ -121,39 +128,13 @@ const NavigationEmbeddablePanelEditor = ({ [orderedLinks] ); - const saveButtonComponent = useMemo(() => { - const canSave = orderedLinks.length !== 0; - - const button = ( - { - const newLinks = orderedLinks.reduce((prev, link, i) => { - return { ...prev, [link.id]: { ...link, order: i } }; - }, {} as NavigationEmbeddableLinkList); - onSave({ links: newLinks }); - }} - > - {NavEmbeddableStrings.editor.panelEditor.getSaveButtonLabel()} - - ); - - return canSave ? ( - button - ) : ( - - {button} - - ); - }, [onSave, orderedLinks]); - return ( <>

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

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

+ } + > + { + setIsSaving(true); + onSaveToLibrary(orderedLinks) + .catch((e) => { + toasts.addError(e, { + title: + NavEmbeddableStrings.editor.panelEditor.getErrorDuringSaveToastTitle(), + }); + }) + .finally(() => { + setIsSaving(false); + }); + }} + > + {initialLinks + ? NavEmbeddableStrings.editor.panelEditor.getUpdateLibraryItemButtonLabel() + : NavEmbeddableStrings.editor.panelEditor.getSaveToLibraryButtonLabel()} + +
+
+ ) : null} +
+
diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx index be65c130222e..7c6d2b726810 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx @@ -21,11 +21,8 @@ import { } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { - NavigationLinkInfo, - DASHBOARD_LINK_TYPE, - NavigationEmbeddableLink, -} from '../embeddable/types'; +import { NavigationLinkInfo } from '../embeddable/types'; +import { DASHBOARD_LINK_TYPE, NavigationEmbeddableLink } from '../../common/content_management'; import { fetchDashboard } from './dashboard_link/dashboard_link_tools'; import { NavEmbeddableStrings } from './navigation_embeddable_strings'; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts index 5628c3444d2d..20953c41dbe8 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts @@ -47,9 +47,38 @@ export const NavEmbeddableStrings = { i18n.translate('navigationEmbeddable.panelEditor.editFlyoutTitle', { defaultMessage: 'Edit links panel', }), - getSaveButtonLabel: () => - i18n.translate('navigationEmbeddable.panelEditor.saveButtonLabel', { - defaultMessage: 'Save', + getApplyButtonLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.applyButtonLabel', { + defaultMessage: 'Apply', + }), + getAddToDashboardButtonLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.addToDashboardButtonLabel', { + defaultMessage: 'Add to dashboard', + }), + getAddToDashboardButtonTooltip: () => + i18n.translate('navigationEmbeddable.panelEditor.addToDashboardButtonTooltip', { + defaultMessage: 'Add this links panel directly to this dashboard.', + }), + getSaveToLibraryButtonLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.saveToLibraryButtonLabel', { + defaultMessage: 'Save to library', + }), + getSaveToLibraryButtonTooltip: () => + i18n.translate('navigationEmbeddable.panelEditor.saveToLibraryButtonTooltip', { + defaultMessage: + 'Save this links panel to the library so you can easily add it to other dashboards.', + }), + getUpdateLibraryItemButtonLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.updateLibraryItemButtonLabel', { + defaultMessage: 'Update library item', + }), + getUpdateLibraryItemButtonTooltip: () => + i18n.translate('navigationEmbeddable.panelEditor.updateLibraryItemButtonTooltip', { + defaultMessage: 'Editing this panel might affect other dashboards.', + }), + getTitleInputLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.titleInputLabel', { + defaultMessage: 'Title', }), getLinkLoadingAriaLabel: () => i18n.translate('navigationEmbeddable.linkEditor.linkLoadingAriaLabel', { @@ -59,6 +88,10 @@ export const NavEmbeddableStrings = { i18n.translate('navigationEmbeddable.editor.dragHandleAriaLabel', { defaultMessage: 'Link drag handle', }), + getErrorDuringSaveToastTitle: () => + i18n.translate('navigationEmbeddable.editor.unableToSaveToastTitle', { + defaultMessage: 'Error saving Link panel', + }), }, linkEditor: { getGoBackAriaLabel: () => diff --git a/src/plugins/navigation_embeddable/public/components/tooltip_wrapper.tsx b/src/plugins/navigation_embeddable/public/components/tooltip_wrapper.tsx new file mode 100644 index 000000000000..a477c62d3bd9 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/tooltip_wrapper.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiToolTip, EuiToolTipProps } from '@elastic/eui'; + +type TooltipWrapperProps = Partial> & { + tooltipContent: string; + /** When the condition is truthy, the tooltip will be shown */ + condition: boolean; +}; + +export const TooltipWrapper: React.FunctionComponent = ({ + children, + condition, + tooltipContent, + ...tooltipProps +}) => { + return ( + <> + {condition ? ( + + <>{children} + + ) : ( + children + )} + + ); +}; diff --git a/src/plugins/navigation_embeddable/public/content_management/duplicate_title_check.ts b/src/plugins/navigation_embeddable/public/content_management/duplicate_title_check.ts new file mode 100644 index 000000000000..3115e110467e --- /dev/null +++ b/src/plugins/navigation_embeddable/public/content_management/duplicate_title_check.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { navigationEmbeddableClient } from './navigation_embeddable_content_management_client.ts'; + +const rejectErrorMessage = i18n.translate('navigationEmbeddable.saveDuplicateRejectedDescription', { + defaultMessage: 'Save with duplicate title confirmation was rejected', +}); + +interface Props { + title: string; + id?: string; + onTitleDuplicate: () => void; + lastSavedTitle: string; + copyOnSave: boolean; + isTitleDuplicateConfirmed: boolean; +} + +export const checkForDuplicateTitle = async ({ + id, + title, + lastSavedTitle, + copyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, +}: Props) => { + if (isTitleDuplicateConfirmed) { + return true; + } + + if (title === lastSavedTitle && !copyOnSave) { + return true; + } + + const { hits } = await navigationEmbeddableClient.search( + { + text: `"${title}"`, + limit: 10, + }, + { onlyTitle: true } + ); + + const existing = hits.find((obj) => obj.attributes.title.toLowerCase() === title.toLowerCase()); + + if (!existing || existing.id === id) { + return true; + } + + onTitleDuplicate(); + return Promise.reject(new Error(rejectErrorMessage)); +}; diff --git a/src/plugins/navigation_embeddable/public/content_management/index.ts b/src/plugins/navigation_embeddable/public/content_management/index.ts new file mode 100644 index 000000000000..883a28a34ad2 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/content_management/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { navigationEmbeddableClient } from './navigation_embeddable_content_management_client.ts'; +export { checkForDuplicateTitle } from './duplicate_title_check'; diff --git a/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts b/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts new file mode 100644 index 000000000000..f7cb54da2393 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/content_management/navigation_embeddable_content_management_client.ts.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SearchQuery } from '@kbn/content-management-plugin/common'; +import type { NavigationEmbeddableCrudTypes } from '../../common/content_management'; +import { CONTENT_ID as contentTypeId } from '../../common'; +import { contentManagement } from '../services/kibana_services'; + +const get = async (id: string) => { + return contentManagement.client.get< + NavigationEmbeddableCrudTypes['GetIn'], + NavigationEmbeddableCrudTypes['GetOut'] + >({ contentTypeId, id }); +}; + +const create = async ({ + data, + options, +}: Omit) => { + const res = await contentManagement.client.create< + NavigationEmbeddableCrudTypes['CreateIn'], + NavigationEmbeddableCrudTypes['CreateOut'] + >({ + contentTypeId, + data, + options, + }); + return res; +}; + +const update = async ({ + id, + data, + options, +}: Omit) => { + const res = await contentManagement.client.update< + NavigationEmbeddableCrudTypes['UpdateIn'], + NavigationEmbeddableCrudTypes['UpdateOut'] + >({ + contentTypeId, + id, + data, + options, + }); + return res; +}; + +const deleteNavigationEmbeddable = async (id: string) => { + await contentManagement.client.delete< + NavigationEmbeddableCrudTypes['DeleteIn'], + NavigationEmbeddableCrudTypes['DeleteOut'] + >({ + contentTypeId, + id, + }); +}; + +const search = async ( + query: SearchQuery = {}, + options?: NavigationEmbeddableCrudTypes['SearchOptions'] +) => { + return contentManagement.client.search< + NavigationEmbeddableCrudTypes['SearchIn'], + NavigationEmbeddableCrudTypes['SearchOut'] + >({ + contentTypeId, + query, + options, + }); +}; + +export const navigationEmbeddableClient = { + get, + create, + update, + delete: deleteNavigationEmbeddable, + search, +}; diff --git a/src/plugins/navigation_embeddable/public/content_management/save_to_library.tsx b/src/plugins/navigation_embeddable/public/content_management/save_to_library.tsx new file mode 100644 index 000000000000..31274817c5cb --- /dev/null +++ b/src/plugins/navigation_embeddable/public/content_management/save_to_library.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { + showSaveModal, + OnSaveProps, + SavedObjectSaveModal, + SaveResult, +} from '@kbn/saved-objects-plugin/public'; + +import { APP_NAME } from '../../common'; +import { NavigationEmbeddableAttributes } from '../../common/content_management'; +import { + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableInput, +} from '../embeddable/types'; +import { checkForDuplicateTitle } from './duplicate_title_check'; +import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; + +export const runSaveToLibrary = async ( + newAttributes: NavigationEmbeddableAttributes, + initialInput: NavigationEmbeddableInput +): Promise => { + return new Promise((resolve) => { + const onSave = async ({ + newTitle, + newDescription, + onTitleDuplicate, + isTitleDuplicateConfirmed, + }: OnSaveProps): Promise => { + const stateFromSaveModal = { + title: newTitle, + description: newDescription, + }; + + if ( + !(await checkForDuplicateTitle({ + title: newTitle, + lastSavedTitle: newAttributes.title, + copyOnSave: false, + onTitleDuplicate, + isTitleDuplicateConfirmed, + })) + ) { + return {}; + } + + const stateToSave = { + ...newAttributes, + ...stateFromSaveModal, + }; + + const updatedInput = (await getNavigationEmbeddableAttributeService().wrapAttributes( + stateToSave, + true, + initialInput + )) as unknown as NavigationEmbeddableByReferenceInput; + + resolve(updatedInput); + return { id: updatedInput.savedObjectId }; + }; + + const saveModal = ( + resolve(undefined)} + title={newAttributes.title} + description={newAttributes.description} + showDescription + showCopyOnSave={false} + objectType={APP_NAME} + /> + ); + showSaveModal(saveModal); + }); +}; diff --git a/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx b/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx index 83cc4cfdc7c4..4248af756f52 100644 --- a/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx +++ b/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx @@ -7,16 +7,12 @@ */ import { memoize } from 'lodash'; -import { NavigationEmbeddableLink, NavigationEmbeddableLinkList } from '../embeddable/types'; +import { NavigationEmbeddableLink } from '../../common/content_management'; -const getOrderedLinkList = (links: NavigationEmbeddableLinkList): NavigationEmbeddableLink[] => { - return Object.keys(links) - .map((linkId) => { - return links[linkId]; - }) - .sort((linkA, linkB) => { - return linkA.order - linkB.order; - }); +const getOrderedLinkList = (links: NavigationEmbeddableLink[]): NavigationEmbeddableLink[] => { + return [...links].sort((linkA, linkB) => { + return linkA.order - linkB.order; + }); }; /** @@ -25,10 +21,10 @@ const getOrderedLinkList = (links: NavigationEmbeddableLinkList): NavigationEmbe * calculated this so, we can get away with using the cached version in the editor */ export const memoizedGetOrderedLinkList = memoize( - (links: NavigationEmbeddableLinkList) => { + (links: NavigationEmbeddableLink[]) => { return getOrderedLinkList(links); }, - (links) => { + (links: NavigationEmbeddableLink[]) => { return links; } ); diff --git a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx index 19edb5fb4f2c..e156180d8dd4 100644 --- a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx +++ b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx @@ -17,8 +17,14 @@ import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { coreServices } from '../services/kibana_services'; -import { NavigationEmbeddableInput } from '../embeddable/types'; +import { + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableInput, +} from '../embeddable/types'; import { memoizedFetchDashboards } from '../components/dashboard_link/dashboard_link_tools'; +import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; +import { NavigationEmbeddableLink } from '../../common/content_management'; +import { runSaveToLibrary } from '../content_management/save_to_library'; const LazyNavigationEmbeddablePanelEditor = React.lazy( () => import('../components/navigation_embeddable_panel_editor') @@ -35,14 +41,42 @@ const NavigationEmbeddablePanelEditor = withSuspense( * @throws in case user cancels */ export async function openEditorFlyout( - initialInput?: Omit, + initialInput: NavigationEmbeddableInput, parentDashboard?: DashboardContainer ): Promise> { + const attributeService = getNavigationEmbeddableAttributeService(); + const { attributes } = await attributeService.unwrapAttributes(initialInput); + const isByReference = attributeService.inputIsRefType(initialInput); + return new Promise((resolve, reject) => { const closed$ = new Subject(); - const onSave = (partialInput: Partial) => { - resolve(partialInput); + const onSaveToLibrary = async (newLinks: NavigationEmbeddableLink[]) => { + const newAttributes = { + ...attributes, + links: newLinks, + }; + const updatedInput = (initialInput as NavigationEmbeddableByReferenceInput).savedObjectId + ? await attributeService.wrapAttributes(newAttributes, true, initialInput) + : await runSaveToLibrary(newAttributes, initialInput); + if (!updatedInput) { + return; + } + resolve(updatedInput); + parentDashboard?.reload(); + editorFlyout.close(); + }; + + const onAddToDashboard = (newLinks: NavigationEmbeddableLink[]) => { + const newInput: NavigationEmbeddableInput = { + ...initialInput, + attributes: { + ...attributes, + links: newLinks, + }, + }; + resolve(newInput); + parentDashboard?.reload(); editorFlyout.close(); }; @@ -63,10 +97,12 @@ export async function openEditorFlyout( const editorFlyout = coreServices.overlays.openFlyout( toMountPoint( , { theme$: coreServices.theme.theme$ } ), diff --git a/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx index 794b8812d793..1fee9bdd9b20 100644 --- a/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx +++ b/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx @@ -13,7 +13,7 @@ import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { coreServices } from '../services/kibana_services'; -import { NavigationEmbeddableLink } from '../embeddable/types'; +import { NavigationEmbeddableLink } from '../../common/content_management'; import { NavigationEmbeddableLinkEditor } from '../components/navigation_embeddable_link_editor'; export interface LinkEditorProps { diff --git a/src/plugins/navigation_embeddable/public/embeddable/index.ts b/src/plugins/navigation_embeddable/public/embeddable/index.ts index 12c60f3ebd00..eeaae0533480 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/index.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/index.ts @@ -6,9 +6,6 @@ * Side Public License, v 1. */ -export { - NAVIGATION_EMBEDDABLE_TYPE, - NavigationEmbeddable as NavigationEmbeddable, -} from './navigation_embeddable'; +export { NavigationEmbeddable } from './navigation_embeddable'; export type { NavigationEmbeddableFactory } from './navigation_embeddable_factory'; export { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable_factory'; diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx index 9f3194d0b376..58a665488b82 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx @@ -7,16 +7,27 @@ */ import React, { createContext, useContext } from 'react'; - -import { Embeddable, EmbeddableOutput } from '@kbn/embeddable-plugin/public'; +import { Subscription } from 'rxjs'; + +import { + AttributeService, + Embeddable, + ReferenceOrValueEmbeddable, + SavedObjectEmbeddableInput, +} from '@kbn/embeddable-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; import { navigationEmbeddableReducers } from './navigation_embeddable_reducers'; -import { NavigationEmbeddableInput, NavigationEmbeddableReduxState } from './types'; +import { + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableByValueInput, + NavigationEmbeddableReduxState, +} from './types'; import { NavigationEmbeddableComponent } from '../components/navigation_embeddable_component'; - -export const NAVIGATION_EMBEDDABLE_TYPE = 'navigation'; +import { NavigationEmbeddableInput, NavigationEmbeddableOutput } from './types'; +import { NavigationEmbeddableAttributes } from '../../common/content_management'; +import { CONTENT_ID } from '../../common'; export const NavigationEmbeddableContext = createContext(null); export const useNavigationEmbeddable = (): NavigationEmbeddable => { @@ -36,8 +47,19 @@ export interface NavigationEmbeddableConfig { editable: boolean; } -export class NavigationEmbeddable extends Embeddable { - public readonly type = NAVIGATION_EMBEDDABLE_TYPE; +export class NavigationEmbeddable + extends Embeddable + implements + ReferenceOrValueEmbeddable< + NavigationEmbeddableByValueInput, + NavigationEmbeddableByReferenceInput + > +{ + public readonly type = CONTENT_ID; + deferEmbeddableLoad = true; + + private isDestroyed?: boolean; + private subscriptions: Subscription = new Subscription(); // state management public select: NavigationReduxEmbeddableTools['select']; @@ -51,6 +73,7 @@ export class NavigationEmbeddable extends Embeddable, parent?: DashboardContainer ) { super( @@ -77,17 +100,66 @@ export class NavigationEmbeddable extends Embeddable this.setInitializationFinished()) + .catch((e: Error) => this.onFatalError(e)); } - public async reload() {} + private async initializeSavedLinks(input: NavigationEmbeddableInput) { + const { attributes } = await this.attributeService.unwrapAttributes(input); + if (this.isDestroyed) return; + + // TODO handle metaInfo + + this.updateInput({ attributes }); + + await this.initializeOutput(); + } + + private async initializeOutput() { + const { attributes } = this.getInput() as NavigationEmbeddableByValueInput; + const { title, description } = this.getInput(); + this.updateOutput({ + defaultTitle: attributes.title, + defaultDescription: attributes.description, + title: title ?? attributes.title, + description: description ?? attributes.description, + }); + } + + public inputIsRefType( + input: NavigationEmbeddableByValueInput | NavigationEmbeddableByReferenceInput + ): input is NavigationEmbeddableByReferenceInput { + return this.attributeService.inputIsRefType(input); + } + + public async getInputAsRefType(): Promise { + return this.attributeService.getInputAsRefType(this.getExplicitInput(), { + showSaveModal: true, + saveModalTitle: this.getTitle(), + }); + } + + public async getInputAsValueType(): Promise { + return this.attributeService.getInputAsValueType(this.getExplicitInput()); + } + + public async reload() { + if (this.isDestroyed) return; + await this.initializeSavedLinks(this.getInput()); + this.render(); + } public destroy() { + this.isDestroyed = true; super.destroy(); + this.subscriptions.unsubscribe(); this.cleanupStateTools(); } public render() { + if (this.isDestroyed) return; return ( diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts index 8a9662492909..9711c81b6412 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts @@ -6,34 +6,63 @@ * Side Public License, v 1. */ -import { isEmpty } from 'lodash'; - -import { i18n } from '@kbn/i18n'; import { ACTION_ADD_PANEL, EmbeddableFactory, EmbeddableFactoryDefinition, + EmbeddablePackageState, + ErrorEmbeddable, } from '@kbn/embeddable-plugin/public'; import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; -import { NavigationEmbeddableInput } from './types'; -import { NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; +import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; +import { + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableByValueInput, + NavigationEmbeddableInput, +} from './types'; +import type { NavigationEmbeddable } from './navigation_embeddable'; import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; +import { getNavigationEmbeddableAttributeService } from '../services/attribute_service'; +import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; export type NavigationEmbeddableFactory = EmbeddableFactory; +export interface NavigationEmbeddableCreationOptions { + getInitialInput?: () => Partial; + getIncomingEmbeddable?: () => EmbeddablePackageState | undefined; +} + // TODO: Replace string 'OPEN_FLYOUT_ADD_DRILLDOWN' with constant as part of https://github.com/elastic/kibana/issues/154381 -const getDefaultNavigationEmbeddableInput = (): Omit => ({ - links: {}, +const getDefaultNavigationEmbeddableInput = (): Omit => ({ + attributes: { + title: '', + }, disabledActions: [ACTION_ADD_PANEL, 'OPEN_FLYOUT_ADD_DRILLDOWN'], }); export class NavigationEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { - public readonly type = NAVIGATION_EMBEDDABLE_TYPE; - public isContainerType = false; + public readonly type = CONTENT_ID; + + public readonly isContainerType = false; + + public readonly savedObjectMetaData = { + name: APP_NAME, + type: CONTENT_ID, + getIconForSavedObject: () => APP_ICON, + }; + + // TODO create functions + // public inject: EmbeddablePersistableStateService['inject']; + // public extract: EmbeddablePersistableStateService['extract']; + + constructor(persistableStateService: EmbeddablePersistableStateService) { + // this.inject = createInject(this.persistableStateService); + // this.extract = createExtract(this.persistableStateService); + } public async isEditable() { await untilPluginStartServicesReady(); @@ -48,12 +77,18 @@ export class NavigationEmbeddableFactoryDefinition return getDefaultNavigationEmbeddableInput(); } - public async create(initialInput: NavigationEmbeddableInput, parent: DashboardContainer) { - if (!initialInput.links || isEmpty(initialInput.links)) { - // don't create an empty navigation embeddable - it should always have at least one link - return; + public async createFromSavedObject( + savedObjectId: string, + input: NavigationEmbeddableInput, + parent: DashboardContainer + ): Promise { + if (!(input as NavigationEmbeddableByReferenceInput).savedObjectId) { + (input as NavigationEmbeddableByReferenceInput).savedObjectId = savedObjectId; } + return this.create(input, parent); + } + public async create(initialInput: NavigationEmbeddableInput, parent: DashboardContainer) { await untilPluginStartServicesReady(); const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage(); @@ -64,33 +99,32 @@ export class NavigationEmbeddableFactoryDefinition reduxEmbeddablePackage, { editable }, { ...getDefaultNavigationEmbeddableInput(), ...initialInput }, + getNavigationEmbeddableAttributeService(), parent ); } public async getExplicitInput( - initialInput?: NavigationEmbeddableInput, + initialInput: NavigationEmbeddableInput, parent?: DashboardContainer - ) { + ): Promise> { if (!parent) return {}; const { openEditorFlyout } = await import('../editor/open_editor_flyout'); const input = await openEditorFlyout( - { ...getDefaultNavigationEmbeddableInput(), ...initialInput }, + { + ...getDefaultNavigationEmbeddableInput(), + ...initialInput, + }, parent - ).catch(() => { - // swallow the promise rejection that happens when the flyout is closed - return {}; - }); + ); return input; } public getDisplayName() { - return i18n.translate('navigationEmbeddable.navigationEmbeddableFactory.displayName', { - defaultMessage: 'Links', - }); + return APP_NAME; } public getIconType() { diff --git a/src/plugins/navigation_embeddable/public/embeddable/types.ts b/src/plugins/navigation_embeddable/public/embeddable/types.ts index 0513d50fc8cb..43d12f611df8 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/types.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/types.ts @@ -6,48 +6,28 @@ * Side Public License, v 1. */ -import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; import { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; -import { EmbeddableInput, EmbeddableOutput } from '@kbn/embeddable-plugin/public'; +import { + EmbeddableInput, + EmbeddableOutput, + SavedObjectEmbeddableInput, +} from '@kbn/embeddable-plugin/public'; +import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; import { ExternalLinkEmbeddableStrings } from '../components/external_link/external_link_strings'; import { DashboardLinkEmbeddableStrings } from '../components/dashboard_link/dashboard_link_strings'; +import { + DASHBOARD_LINK_TYPE, + EXTERNAL_LINK_TYPE, + NavigationLinkType, + NavigationEmbeddableAttributes, +} from '../../common/content_management'; -/** - * Dashboard to dashboard links - */ -export const DASHBOARD_LINK_TYPE = 'dashboardLink'; export interface DashboardItem { id: string; attributes: DashboardAttributes; } -/** - * External URL links - */ -export const EXTERNAL_LINK_TYPE = 'externalLink'; - -/** - * Navigation embeddable explicit input - */ -export type NavigationLinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; - -export interface NavigationEmbeddableLink { - id: string; - type: NavigationLinkType; - destination: string; - label?: string; - order: number; -} - -export interface NavigationEmbeddableLinkList { - [id: string]: NavigationEmbeddableLink; -} - -export interface NavigationEmbeddableInput extends EmbeddableInput { - links: NavigationEmbeddableLinkList; -} - export const NavigationLinkInfo: { [id in NavigationLinkType]: { icon: string; displayName: string; description: string }; } = { @@ -63,6 +43,20 @@ export const NavigationLinkInfo: { }, }; +export type NavigationEmbeddableByValueInput = { + attributes: NavigationEmbeddableAttributes; +} & EmbeddableInput; + +export type NavigationEmbeddableByReferenceInput = SavedObjectEmbeddableInput; + +export type NavigationEmbeddableInput = + | NavigationEmbeddableByValueInput + | NavigationEmbeddableByReferenceInput; + +export type NavigationEmbeddableOutput = EmbeddableOutput & { + attributes?: NavigationEmbeddableAttributes; +}; + /** * Navigation embeddable redux state */ @@ -70,6 +64,6 @@ export const NavigationLinkInfo: { export type NavigationEmbeddableReduxState = ReduxEmbeddableState< NavigationEmbeddableInput, - EmbeddableOutput, + NavigationEmbeddableOutput, {} // We currently don't have any component state - TODO: Replace with `NavigationEmbeddableComponentState` if necessary >; diff --git a/src/plugins/navigation_embeddable/public/index.ts b/src/plugins/navigation_embeddable/public/index.ts index 9cdbcfcc6c66..d1655bd9bc25 100644 --- a/src/plugins/navigation_embeddable/public/index.ts +++ b/src/plugins/navigation_embeddable/public/index.ts @@ -7,11 +7,7 @@ */ export type { NavigationEmbeddableFactory } from './embeddable'; -export { - NAVIGATION_EMBEDDABLE_TYPE, - NavigationEmbeddableFactoryDefinition, - NavigationEmbeddable, -} from './embeddable'; +export { NavigationEmbeddableFactoryDefinition, NavigationEmbeddable } from './embeddable'; import { NavigationEmbeddablePlugin } from './plugin'; diff --git a/src/plugins/navigation_embeddable/public/plugin.ts b/src/plugins/navigation_embeddable/public/plugin.ts index 7a969c0298f7..8863c4393b05 100644 --- a/src/plugins/navigation_embeddable/public/plugin.ts +++ b/src/plugins/navigation_embeddable/public/plugin.ts @@ -6,20 +6,26 @@ * Side Public License, v 1. */ -import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { + ContentManagementPublicSetup, + ContentManagementPublicStart, +} from '@kbn/content-management-plugin/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; - -import { NAVIGATION_EMBEDDABLE_TYPE } from './embeddable'; -import { setKibanaServices } from './services/kibana_services'; import { NavigationEmbeddableFactoryDefinition } from './embeddable'; +import { CONTENT_ID, LATEST_VERSION } from '../common'; +import { APP_NAME } from '../common'; +import { setKibanaServices } from './services/kibana_services'; export interface NavigationEmbeddableSetupDependencies { embeddable: EmbeddableSetup; + contentManagement: ContentManagementPublicSetup; } export interface NavigationEmbeddableStartDependencies { embeddable: EmbeddableStart; + contentManagement: ContentManagementPublicStart; dashboard: DashboardStart; } @@ -40,14 +46,23 @@ export class NavigationEmbeddablePlugin ) { core.getStartServices().then(([_, deps]) => { plugins.embeddable.registerEmbeddableFactory( - NAVIGATION_EMBEDDABLE_TYPE, - new NavigationEmbeddableFactoryDefinition() + CONTENT_ID, + new NavigationEmbeddableFactoryDefinition(deps.embeddable) ); + + plugins.contentManagement.registry.register({ + id: CONTENT_ID, + version: { + latest: LATEST_VERSION, + }, + name: APP_NAME, + }); }); } public start(core: CoreStart, plugins: NavigationEmbeddableStartDependencies) { setKibanaServices(core, plugins); + return {}; } public stop() {} diff --git a/src/plugins/navigation_embeddable/public/services/attribute_service.ts b/src/plugins/navigation_embeddable/public/services/attribute_service.ts new file mode 100644 index 000000000000..7a7dbfe2bd13 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/services/attribute_service.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Reference } from '@kbn/content-management-utils'; +import { AttributeService } from '@kbn/embeddable-plugin/public'; +import type { OnSaveProps } from '@kbn/saved-objects-plugin/public'; +import { SharingSavedObjectProps } from '../../common/types'; +import { NavigationEmbeddableAttributes } from '../../common/content_management'; +import { + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableByValueInput, +} from '../embeddable/types'; +import { embeddableService } from './kibana_services'; +import { checkForDuplicateTitle, navigationEmbeddableClient } from '../content_management'; +import { CONTENT_ID } from '../../common'; + +export type NavigationEmbeddableDocument = NavigationEmbeddableAttributes & { + references?: Reference[]; +}; + +export interface NavigationEmbeddableUnwrapMetaInfo { + sharingSavedObjectProps?: SharingSavedObjectProps; +} + +export type NavigationEmbeddableAttributeService = AttributeService< + NavigationEmbeddableDocument, + NavigationEmbeddableByValueInput, + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableUnwrapMetaInfo +>; + +let navigationEmbeddableAttributeService: NavigationEmbeddableAttributeService | null = null; +export function getNavigationEmbeddableAttributeService(): NavigationEmbeddableAttributeService { + if (navigationEmbeddableAttributeService) return navigationEmbeddableAttributeService; + + navigationEmbeddableAttributeService = embeddableService.getAttributeService< + NavigationEmbeddableDocument, + NavigationEmbeddableByValueInput, + NavigationEmbeddableByReferenceInput, + NavigationEmbeddableUnwrapMetaInfo + >(CONTENT_ID, { + saveMethod: async (attributes: NavigationEmbeddableDocument, savedObjectId?: string) => { + // TODO extract references + const { + item: { id }, + } = await (savedObjectId + ? navigationEmbeddableClient.update({ id: savedObjectId, data: attributes }) + : navigationEmbeddableClient.create({ data: attributes, options: { references: [] } })); + return { id }; + }, + unwrapMethod: async ( + savedObjectId: string + ): Promise<{ + attributes: NavigationEmbeddableDocument; + metaInfo: NavigationEmbeddableUnwrapMetaInfo; + }> => { + const { + item: savedObject, + meta: { outcome, aliasPurpose, aliasTargetId }, + } = await navigationEmbeddableClient.get(savedObjectId); + if (savedObject.error) throw savedObject.error; + + // TODO inject references + const attributes = savedObject.attributes; + return { + attributes, + metaInfo: { + sharingSavedObjectProps: { + aliasTargetId, + outcome, + aliasPurpose, + sourceId: savedObjectId, + }, + }, + }; + }, + checkForDuplicateTitle: (props: OnSaveProps) => { + return checkForDuplicateTitle({ + title: props.newTitle, + copyOnSave: false, + lastSavedTitle: '', + isTitleDuplicateConfirmed: props.isTitleDuplicateConfirmed, + onTitleDuplicate: props.onTitleDuplicate, + }); + }, + }); + return navigationEmbeddableAttributeService; +} diff --git a/src/plugins/navigation_embeddable/public/services/kibana_services.ts b/src/plugins/navigation_embeddable/public/services/kibana_services.ts index 710c6227a356..ddc5daad6495 100644 --- a/src/plugins/navigation_embeddable/public/services/kibana_services.ts +++ b/src/plugins/navigation_embeddable/public/services/kibana_services.ts @@ -10,11 +10,15 @@ import { BehaviorSubject } from 'rxjs'; import { CoreStart } from '@kbn/core/public'; import { DashboardStart } from '@kbn/dashboard-plugin/public'; +import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import { NavigationEmbeddableStartDependencies } from '../plugin'; export let coreServices: CoreStart; export let dashboardServices: DashboardStart; +export let embeddableService: EmbeddableStart; +export let contentManagement: ContentManagementPublicStart; const servicesReady$ = new BehaviorSubject(false); @@ -36,6 +40,8 @@ export const setKibanaServices = ( ) => { coreServices = kibanaCore; dashboardServices = deps.dashboard; + embeddableService = deps.embeddable; + contentManagement = deps.contentManagement; servicesReady$.next(true); }; diff --git a/src/plugins/navigation_embeddable/server/content_management/index.ts b/src/plugins/navigation_embeddable/server/content_management/index.ts new file mode 100644 index 000000000000..2376765bcac8 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/content_management/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { NavigationEmbeddableStorage } from './navigation_embeddable_storage'; diff --git a/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts b/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts new file mode 100644 index 000000000000..07318f62a3e1 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/content_management/navigation_embeddable_storage.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SOContentStorage } from '@kbn/content-management-utils'; +import { CONTENT_ID } from '../../common'; +import type { NavigationEmbeddableCrudTypes } from '../../common/content_management'; +import { cmServicesDefinition } from '../../common/content_management/cm_services'; + +export class NavigationEmbeddableStorage extends SOContentStorage { + constructor() { + super({ + savedObjectType: CONTENT_ID, + cmServicesDefinition, + enableMSearch: true, + allowedSavedObjectAttributes: ['id', 'title', 'description', 'links'], + }); + } +} diff --git a/src/plugins/navigation_embeddable/server/index.ts b/src/plugins/navigation_embeddable/server/index.ts new file mode 100644 index 000000000000..6ececdd95b5d --- /dev/null +++ b/src/plugins/navigation_embeddable/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { NavigationEmbeddableServerPlugin } from './plugin'; + +export const plugin = () => new NavigationEmbeddableServerPlugin(); diff --git a/src/plugins/navigation_embeddable/server/plugin.ts b/src/plugins/navigation_embeddable/server/plugin.ts new file mode 100644 index 000000000000..05e3ca79f997 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/plugin.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; +import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; +import { CONTENT_ID, LATEST_VERSION } from '../common'; +import { NavigationEmbeddableAttributes } from '../common/content_management'; +import { NavigationEmbeddableStorage } from './content_management'; +import { navigationEmbeddableSavedObjectType } from './saved_objects'; + +export class NavigationEmbeddableServerPlugin implements Plugin { + public setup( + core: CoreSetup, + plugins: { + contentManagement: ContentManagementServerSetup; + } + ) { + plugins.contentManagement.register({ + id: CONTENT_ID, + storage: new NavigationEmbeddableStorage(), + version: { + latest: LATEST_VERSION, + }, + }); + + core.savedObjects.registerType( + navigationEmbeddableSavedObjectType + ); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/navigation_embeddable/server/saved_objects/index.ts b/src/plugins/navigation_embeddable/server/saved_objects/index.ts new file mode 100644 index 000000000000..1c33d5995942 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/saved_objects/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { navigationEmbeddableSavedObjectType } from './navigation_embeddable'; diff --git a/src/plugins/navigation_embeddable/server/saved_objects/navigation_embeddable.ts b/src/plugins/navigation_embeddable/server/saved_objects/navigation_embeddable.ts new file mode 100644 index 000000000000..0a2bfbd97897 --- /dev/null +++ b/src/plugins/navigation_embeddable/server/saved_objects/navigation_embeddable.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectsType } from '@kbn/core/server'; +import { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { APP_ICON, CONTENT_ID } from '../../common'; + +export const navigationEmbeddableSavedObjectType: SavedObjectsType = { + name: CONTENT_ID, + indexPattern: ANALYTICS_SAVED_OBJECT_INDEX, + hidden: false, + namespaceType: 'multiple', + management: { + icon: APP_ICON, + defaultSearchField: 'title', + importableAndExportable: true, + getTitle(obj) { + return obj.attributes.title; + }, + }, + mappings: { + properties: { + id: { type: 'text' }, + title: { type: 'text' }, + description: { type: 'text' }, + links: { + dynamic: false, + properties: {}, + }, + }, + }, + migrations: () => { + return {}; + }, +}; diff --git a/src/plugins/navigation_embeddable/tsconfig.json b/src/plugins/navigation_embeddable/tsconfig.json index 3c1cee2edb3d..ae66fa7cc314 100644 --- a/src/plugins/navigation_embeddable/tsconfig.json +++ b/src/plugins/navigation_embeddable/tsconfig.json @@ -11,7 +11,15 @@ "@kbn/embeddable-plugin", "@kbn/kibana-react-plugin", "@kbn/presentation-util-plugin", + "@kbn/object-versioning", + "@kbn/config-schema", + "@kbn/content-management-utils", + "@kbn/content-management-plugin", "@kbn/shared-ux-utility", + "@kbn/core-saved-objects-api-server", + "@kbn/saved-objects-plugin", + "@kbn/core-saved-objects-server", + "@kbn/saved-objects-plugin", ], "exclude": ["target/**/*"] }