diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 3b1e56ddc9010..0afb3e5c4ee68 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -100,6 +100,7 @@ enabled: - test/functional/apps/dashboard_elements/controls/options_list/config.ts - test/functional/apps/dashboard_elements/image_embeddable/config.ts - test/functional/apps/dashboard_elements/input_control_vis/config.ts + - test/functional/apps/dashboard_elements/links/config.ts - test/functional/apps/dashboard_elements/markdown/config.ts - test/functional/apps/dashboard/group1/config.ts - test/functional/apps/dashboard/group2/config.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c9f1926a01d21..7f8a9fce8b716 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -470,6 +470,7 @@ x-pack/plugins/lens @elastic/kibana-visualizations x-pack/plugins/license_api_guard @elastic/platform-deployment-management x-pack/plugins/license_management @elastic/platform-deployment-management x-pack/plugins/licensing @elastic/kibana-core +src/plugins/links @elastic/kibana-presentation packages/kbn-lint-packages-cli @elastic/kibana-operations packages/kbn-lint-ts-projects-cli @elastic/kibana-operations x-pack/plugins/lists @elastic/security-detection-engine diff --git a/.i18nrc.json b/.i18nrc.json index 4657840019f6c..266db6c1f1577 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -83,6 +83,7 @@ ], "monaco": "packages/kbn-monaco/src", "navigation": "src/plugins/navigation", + "links": "src/plugins/links", "newsfeed": "src/plugins/newsfeed", "presentationUtil": "src/plugins/presentation_util", "randomSampling": "x-pack/packages/kbn-random-sampling", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index ccea77d906970..40a5135d25a5b 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -238,6 +238,10 @@ in Kibana, e.g. visualizations. It has the form of a flyout panel. |Utilities for building Kibana plugins. +|{kib-repo}blob/{branch}/src/plugins/links/README.md[links] +|This plugin adds the Links panel which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard. + + |{kib-repo}blob/{branch}/src/plugins/management/README.md[management] |This plugins contains the "Stack Management" page framework. It offers navigation and an API to link individual management section into it. This plugin does not contain any individual diff --git a/package.json b/package.json index f2d5295783e26..6ea5ef3486efe 100644 --- a/package.json +++ b/package.json @@ -496,6 +496,7 @@ "@kbn/license-api-guard-plugin": "link:x-pack/plugins/license_api_guard", "@kbn/license-management-plugin": "link:x-pack/plugins/license_management", "@kbn/licensing-plugin": "link:x-pack/plugins/licensing", + "@kbn/links-plugin": "link:src/plugins/links", "@kbn/lists-plugin": "link:x-pack/plugins/lists", "@kbn/locator-examples-plugin": "link:examples/locator_examples", "@kbn/locator-explorer-plugin": "link:examples/locator_explorer", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index d23a717c01e49..c18777c8b052d 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1062,6 +1062,24 @@ } } }, + "links": { + "dynamic": false, + "properties": { + "id": { + "type": "text" + }, + "title": { + "type": "text" + }, + "description": { + "type": "text" + }, + "links": { + "dynamic": false, + "properties": {} + } + } + }, "lens": { "properties": { "title": { diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 9b12708a506fc..c319208456963 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -87,6 +87,7 @@ pageLoadAssetSize: lens: 38000 licenseManagement: 41817 licensing: 29004 + links: 44490 lists: 22900 logExplorer: 39045 logsShared: 281060 diff --git a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts index c64c9d7a543aa..63e60a0eb19e4 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts @@ -96,6 +96,7 @@ const STANDARD_LIST_TYPES = [ 'dashboard', 'search', 'lens', + 'links', 'map', 'cases', // synthetics based objects diff --git a/src/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 b624d4871a1b6..ec5fd0ca2add5 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -113,6 +113,7 @@ describe('checking migration metadata changes on all registered SO types', () => "legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8", "lens": "5cfa2c52b979b4f8df56dd13c477e152183468b9", "lens-ui-telemetry": "8c47a9e393861f76e268345ecbadfc8a5fb1e0bd", + "links": "39117a08966e9082d0f47b0b2e7e508499fc1e6d", "maintenance-window": "d893544460abad56ff7a0e25b78f78776dfe10d1", "map": "76c71023bd198fb6b1163b31bafd926fe2ceb9da", "metrics-data-source": "81b69dc9830699d9ead5ac8dcb9264612e2a3c89", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts index 1809437f3cdcd..efb439e058cc2 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts @@ -81,6 +81,7 @@ const previouslyRegisteredTypes = [ 'legacy-url-alias', 'lens', 'lens-ui-telemetry', + 'links', 'maintenance-window', 'map', 'maps-telemetry', diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index 25dc5a46a6793..6a4bc23a814b1 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -438,6 +438,7 @@ describe('split .kibana index into multiple system indices', () => { "legacy-url-alias", "lens", "lens-ui-telemetry", + "links", "maintenance-window", "map", "metrics-data-source", diff --git a/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx b/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx index 95fcda036c329..9b54d83e3fb68 100644 --- a/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx +++ b/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx @@ -92,7 +92,11 @@ export const EditControlFlyout = ({ } closeFlyout(); - await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type); + if (panel.type === type) { + controlGroup.updateInputForChild(embeddable.id, inputToReturn); + } else { + await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type); + } }; return ( diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index 6344c768eae63..2bf18a70a97e7 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -222,22 +222,22 @@ export class ControlGroupContainer extends Container< public async addDataControlFromField(controlProps: AddDataControlProps) { const panelState = await getDataControlPanelState(this.getInput(), controlProps); - return this.createAndSaveEmbeddable(panelState.type, panelState); + return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels); } public addOptionsListControl(controlProps: AddOptionsListControlProps) { const panelState = getOptionsListPanelState(this.getInput(), controlProps); - return this.createAndSaveEmbeddable(panelState.type, panelState); + return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels); } public addRangeSliderControl(controlProps: AddRangeSliderControlProps) { const panelState = getRangeSliderPanelState(this.getInput(), controlProps); - return this.createAndSaveEmbeddable(panelState.type, panelState); + return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels); } public addTimeSliderControl() { const panelState = getTimeSliderPanelState(this.getInput()); - return this.createAndSaveEmbeddable(panelState.type, panelState); + return this.createAndSaveEmbeddable(panelState.type, panelState, this.getInput().panels); } public openAddDataControlFlyout = openAddDataControlFlyout; @@ -283,15 +283,19 @@ export class ControlGroupContainer extends Container< protected createNewPanelState( factory: EmbeddableFactory, - partial: Partial = {} - ): ControlPanelState { - const panelState = super.createNewPanelState(factory, partial); + partial: Partial = {}, + otherPanels: ControlGroupInput['panels'] + ) { + const { newPanel } = super.createNewPanelState(factory, partial); return { - order: getNextPanelOrder(this.getInput().panels), - width: this.getInput().defaultControlWidth, - grow: this.getInput().defaultControlGrow, - ...panelState, - } as ControlPanelState; + newPanel: { + order: getNextPanelOrder(this.getInput().panels), + width: this.getInput().defaultControlWidth, + grow: this.getInput().defaultControlGrow, + ...newPanel, + } as ControlPanelState, + otherPanels, + }; } protected onRemoveEmbeddable(idToRemove: string) { diff --git a/src/plugins/dashboard/jest_setup.ts b/src/plugins/dashboard/jest_setup.ts index 5683ecd4e288b..68be9b5227e49 100644 --- a/src/plugins/dashboard/jest_setup.ts +++ b/src/plugins/dashboard/jest_setup.ts @@ -6,7 +6,13 @@ * Side Public License, v 1. */ -import { pluginServices } from './public/services/plugin_services'; -import { registry } from './public/services/plugin_services.stub'; +import { setStubDashboardServices } from './public/services/mocks'; -pluginServices.setRegistry(registry.start({})); +/** + * CAUTION: Be very mindful of the things you import in to this `jest_setup` file - anything that is imported + * here (either directly or implicitly through dependencies) will be **unable** to be mocked elsewhere! + * + * Refer to the "Caution" section here: + * https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options + */ +setStubDashboardServices(); diff --git a/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.test.tsx index 5ec0ac57c574b..76b62f28993ad 100644 --- a/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.test.tsx @@ -47,11 +47,13 @@ beforeEach(async () => { .fn() .mockReturnValue(mockEmbeddableFactory); container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Kibanana', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Kibanana', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); diff --git a/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx index 8b3e9545114ff..273a7c3040a36 100644 --- a/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx @@ -22,10 +22,9 @@ import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; import { type DashboardPanelState } from '../../common'; import { pluginServices } from '../services/plugin_services'; -import { createPanelState } from '../dashboard_container/component/panel'; import { dashboardClonePanelActionStrings } from './_dashboard_actions_strings'; +import { placeClonePanel } from '../dashboard_container/component/panel_placement'; import { DASHBOARD_CONTAINER_TYPE, type DashboardContainer } from '../dashboard_container'; -import { placePanelBeside } from '../dashboard_container/component/panel/dashboard_panel_placement'; export const ACTION_CLONE_PANEL = 'clonePanel'; @@ -82,6 +81,7 @@ export class ClonePanelAction implements Action { throw new PanelNotFoundError(); } + // Clone panel input const clonedPanelState: PanelState = await (async () => { const newTitle = await this.getCloneTitle(embeddable, embeddable.getTitle() || ''); const id = uuidv4(); @@ -110,18 +110,20 @@ export class ClonePanelAction implements Action { 'data-test-subj': 'addObjectToContainerSuccess', }); - const { otherPanels, newPanel } = createPanelState( - clonedPanelState, - dashboard.getInput().panels, - placePanelBeside, - { - width: panelToClone.gridData.w, - height: panelToClone.gridData.h, - currentPanels: dashboard.getInput().panels, - placeBesideId: panelToClone.explicitInput.id, - scrollToPanel: true, - } - ); + const { newPanelPlacement, otherPanels } = placeClonePanel({ + width: panelToClone.gridData.w, + height: panelToClone.gridData.h, + currentPanels: dashboard.getInput().panels, + placeBesideId: panelToClone.explicitInput.id, + }); + + const newPanel = { + ...clonedPanelState, + gridData: { + ...newPanelPlacement, + i: clonedPanelState.explicitInput.id, + }, + }; dashboard.updateInput({ panels: { diff --git a/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.test.tsx index 877488c6d8041..194edc675b108 100644 --- a/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.test.tsx @@ -31,11 +31,13 @@ pluginServices.getServices().embeddable.getEmbeddableFactory = jest beforeEach(async () => { container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); diff --git a/src/plugins/dashboard/public/dashboard_actions/export_csv_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/export_csv_action.test.tsx index 350db8fad40b1..0fbbe9c76b2cf 100644 --- a/src/plugins/dashboard/public/dashboard_actions/export_csv_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/export_csv_action.test.tsx @@ -46,11 +46,13 @@ describe('Export CSV action', () => { }; container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Kibanana', id: '123' }, - type: CONTACT_CARD_EXPORTABLE_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Kibanana', id: '123' }, + type: CONTACT_CARD_EXPORTABLE_EMBEDDABLE, + }), + }, }, }); diff --git a/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx index 0829f89424ede..5873253e105d4 100644 --- a/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx @@ -29,11 +29,13 @@ let container: DashboardContainer; let embeddable: ContactCardEmbeddable; beforeEach(async () => { container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); diff --git a/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx index 14067f0b6aa68..6f93b08a2708f 100644 --- a/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx @@ -18,7 +18,6 @@ import { } from '@kbn/embeddable-plugin/public'; import { Toast } from '@kbn/core/public'; -import { DashboardPanelState } from '../../common'; import { pluginServices } from '../services/plugin_services'; import { dashboardReplacePanelActionStrings } from './_dashboard_actions_strings'; import { DashboardContainer } from '../dashboard_container'; @@ -58,30 +57,15 @@ export class ReplacePanelFlyout extends React.Component { public onReplacePanel = async (savedObjectId: string, type: string, name: string) => { const { panelToRemove, container } = this.props; - const { w, h, x, y } = (container.getInput().panels[panelToRemove.id] as DashboardPanelState) - .gridData; - const { id } = await container.addNewEmbeddable(type, { - savedObjectId, - }); - - const { [panelToRemove.id]: omit, ...panels } = container.getInput().panels; - - container.updateInput({ - panels: { - ...panels, - [id]: { - ...panels[id], - gridData: { - ...(panels[id] as DashboardPanelState).gridData, - w, - h, - x, - y, - }, - } as DashboardPanelState, + const id = await container.replaceEmbeddable( + panelToRemove.id, + { + savedObjectId, }, - }); + type, + true + ); (container as DashboardContainer).setHighlightPanelId(id); this.showToast(name); diff --git a/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts b/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts new file mode 100644 index 0000000000000..f2805d10c29da --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isQuery, isTimeRange } from '@kbn/data-plugin/common'; +import { Filter, isFilterPinned, Query, TimeRange } from '@kbn/es-query'; +import { EmbeddableInput, IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; + +import { DashboardAppLocatorParams } from './locator'; + +interface EmbeddableQueryInput extends EmbeddableInput { + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; +} + +export const getEmbeddableParams = ( + source: IEmbeddable, + options: DashboardDrilldownOptions +): Partial => { + const params: DashboardAppLocatorParams = {}; + + const input = source.getInput(); + if (isQuery(input.query) && options.useCurrentFilters) { + params.query = input.query; + } + + // if useCurrentDashboardDataRange is enabled, then preserve current time range + // if undefined is passed, then destination dashboard will figure out time range itself + // for brush event this time range would be overwritten + if (isTimeRange(input.timeRange) && options.useCurrentDateRange) { + params.timeRange = input.timeRange; + } + + // if useCurrentDashboardFilters enabled, then preserve all the filters (pinned, unpinned, and from controls) + // otherwise preserve only pinned + params.filters = options.useCurrentFilters + ? input.filters + : input.filters?.filter((f) => isFilterPinned(f)); + + return params; +}; diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx index 71ee49eed260b..63dd1d96d1169 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx @@ -12,8 +12,9 @@ import { METRIC_TYPE } from '@kbn/analytics'; import { useEuiTheme } from '@elastic/eui'; import { AddFromLibraryButton, Toolbar, ToolbarButton } from '@kbn/shared-ux-button-toolbar'; -import { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { EmbeddableFactory, EmbeddableInput } from '@kbn/embeddable-plugin/public'; import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public'; +import { isExplicitInputWithAttributes } from '@kbn/embeddable-plugin/public'; import { getCreateVisualizationButtonTitle } from '../_dashboard_app_strings'; import { EditorMenu } from './editor_menu'; @@ -83,15 +84,26 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean } trackUiMetric(METRIC_TYPE.CLICK, embeddableFactory.type); } - let explicitInput: Awaited>; + let explicitInput: Partial; + let attributes: unknown; try { - explicitInput = await embeddableFactory.getExplicitInput(); + const explicitInputReturn = await embeddableFactory.getExplicitInput(undefined, dashboard); + if (isExplicitInputWithAttributes(explicitInputReturn)) { + explicitInput = explicitInputReturn.newInput; + attributes = explicitInputReturn.attributes; + } else { + explicitInput = explicitInputReturn; + } } catch (e) { // error likely means user canceled embeddable creation return; } - const newEmbeddable = await dashboard.addNewEmbeddable(embeddableFactory.type, explicitInput); + const newEmbeddable = await dashboard.addNewEmbeddable( + embeddableFactory.type, + explicitInput, + attributes + ); if (newEmbeddable) { dashboard.setScrollToPanelId(newEmbeddable.id); diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index 572d1b9d0f11a..1764f55a176e7 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -65,8 +65,14 @@ export const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2; export const CHANGE_CHECK_DEBOUNCE = 100; +// ------------------------------------------------------------------ +// Content Management +// ------------------------------------------------------------------ export { CONTENT_ID as DASHBOARD_CONTENT_ID } from '../common/content_management/constants'; +export const DASHBOARD_CACHE_SIZE = 20; // only store a max of 20 dashboards +export const DASHBOARD_CACHE_TTL = 1000 * 60 * 5; // time to live = 5 minutes + // ------------------------------------------------------------------ // Default State // ------------------------------------------------------------------ diff --git a/src/plugins/dashboard/public/dashboard_container/_dashboard_container.scss b/src/plugins/dashboard/public/dashboard_container/_dashboard_container.scss index aa5b5950a0d59..12c11f778d616 100644 --- a/src/plugins/dashboard/public/dashboard_container/_dashboard_container.scss +++ b/src/plugins/dashboard/public/dashboard_container/_dashboard_container.scss @@ -1,7 +1,6 @@ @import '../../../embeddable/public/variables'; @import './component/grid/index'; -@import './component/panel/index'; @import './component/viewport/index'; .dashboardContainer, .dashboardViewport { diff --git a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx index 439fc43ce8eb0..fb2f6e2f16b28 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.test.tsx @@ -22,7 +22,7 @@ pluginServices.getServices().visualizations.getAliases = jest describe('DashboardEmptyScreen', () => { function mountComponent(viewMode: ViewMode) { - const dashboardContainer = buildMockDashboard({ viewMode }); + const dashboardContainer = buildMockDashboard({ overrides: { viewMode } }); return mountWithIntl( diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss b/src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_panel.scss similarity index 100% rename from src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss rename to src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_panel.scss diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/_index.scss b/src/plugins/dashboard/public/dashboard_container/component/grid/_index.scss index eb393d7603b8a..cb324e984f7ef 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/_index.scss +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/_index.scss @@ -1 +1,2 @@ @import './dashboard_grid'; +@import './dashboard_panel'; \ No newline at end of file diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx index 6c8a123d19588..93f25962a0916 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx @@ -46,16 +46,18 @@ jest.mock('./dashboard_grid_item', () => { const createAndMountDashboardGrid = () => { const dashboardContainer = buildMockDashboard({ - panels: { - '1': { - gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' }, - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { id: '1' }, - }, - '2': { - gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' }, - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { id: '2' }, + overrides: { + panels: { + '1': { + gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' }, + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { id: '1' }, + }, + '2': { + gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' }, + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { id: '2' }, + }, }, }, }); diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx index e496939e8ade0..868bd3d535aa1 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx @@ -88,6 +88,8 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => { const onLayoutChange = useCallback( (newLayout: Array) => { + if (viewMode !== ViewMode.EDIT) return; + const updatedPanels: { [key: string]: DashboardPanelState } = newLayout.reduce( (updatedPanelsAcc, panelLayout) => { updatedPanelsAcc[panelLayout.i] = { @@ -102,7 +104,7 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => { dashboard.dispatch.setPanels(updatedPanels); } }, - [dashboard, panels] + [dashboard, panels, viewMode] ); const classes = classNames({ @@ -127,8 +129,7 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => { className={classes} width={viewportWidth} breakpoints={breakpoints} - onDragStop={onLayoutChange} - onResizeStop={onLayoutChange} + onLayoutChange={onLayoutChange} isResizable={!expandedPanelId && !focusedPanelId} isDraggable={!expandedPanelId && !focusedPanelId} rowHeight={DASHBOARD_GRID_HEIGHT} diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx index fa26677ba1f17..66e2c7c8f83ea 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx @@ -43,7 +43,7 @@ const createAndMountDashboardGridItem = (props: DashboardGridItemProps) => { explicitInput: { id: '2' }, }, }; - const dashboardContainer = buildMockDashboard({ panels }); + const dashboardContainer = buildMockDashboard({ overrides: { panels } }); const component = mountWithIntl( diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/_index.scss b/src/plugins/dashboard/public/dashboard_container/component/panel/_index.scss deleted file mode 100644 index 8212aad12abf1..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './dashboard_panel'; diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.test.ts b/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.test.ts deleted file mode 100644 index acfec6de31d08..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { DashboardPanelState } from '../../../../common'; -import { EmbeddableInput } from '@kbn/embeddable-plugin/public'; -import { CONTACT_CARD_EMBEDDABLE } from '@kbn/embeddable-plugin/public/lib/test_samples'; -import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants'; - -import { createPanelState } from './create_panel_state'; - -interface TestInput extends EmbeddableInput { - test: string; -} -const panels: { [key: string]: DashboardPanelState } = {}; - -test('createPanelState adds a new panel state in 0,0 position', () => { - const { newPanel: panelState } = createPanelState( - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'hi', id: '123' }, - }, - panels - ); - expect(panelState.explicitInput.test).toBe('hi'); - expect(panelState.type).toBe(CONTACT_CARD_EMBEDDABLE); - expect(panelState.explicitInput.id).toBeDefined(); - expect(panelState.gridData.x).toBe(0); - expect(panelState.gridData.y).toBe(0); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); - - panels[panelState.explicitInput.id] = panelState; -}); - -test('createPanelState adds a second new panel state', () => { - const { newPanel: panelState } = createPanelState( - { type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'bye', id: '456' } }, - panels - ); - - expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH); - expect(panelState.gridData.y).toBe(0); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); - - panels[panelState.explicitInput.id] = panelState; -}); - -test('createPanelState adds a third new panel state', () => { - const { newPanel: panelState } = createPanelState( - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'bye', id: '789' }, - }, - panels - ); - expect(panelState.gridData.x).toBe(0); - expect(panelState.gridData.y).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); - - panels[panelState.explicitInput.id] = panelState; -}); - -test('createPanelState adds a new panel state in the top most position', () => { - delete panels['456']; - const { newPanel: panelState } = createPanelState( - { - type: CONTACT_CARD_EMBEDDABLE, - explicitInput: { test: 'bye', id: '987' }, - }, - panels - ); - expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH); - expect(panelState.gridData.y).toBe(0); - expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); - expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); -}); diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.ts b/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.ts deleted file mode 100644 index 8f060f26cfe51..0000000000000 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.ts +++ /dev/null @@ -1,60 +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 { PanelState, EmbeddableInput } from '@kbn/embeddable-plugin/public'; - -import { - IPanelPlacementArgs, - findTopLeftMostOpenSpace, - PanelPlacementMethod, -} from './dashboard_panel_placement'; -import { DashboardPanelState } from '../../../../common'; -import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants'; - -/** - * Creates and initializes a basic panel state. - */ -export function createPanelState< - TEmbeddableInput extends EmbeddableInput, - TPlacementMethodArgs extends IPanelPlacementArgs = IPanelPlacementArgs ->( - panelState: PanelState, - currentPanels: { [key: string]: DashboardPanelState }, - placementMethod?: PanelPlacementMethod, - placementArgs?: TPlacementMethodArgs -): { - newPanel: DashboardPanelState; - otherPanels: { [key: string]: DashboardPanelState }; -} { - const defaultPlacementArgs = { - width: DEFAULT_PANEL_WIDTH, - height: DEFAULT_PANEL_HEIGHT, - currentPanels, - }; - const finalPlacementArgs = placementArgs - ? { - ...defaultPlacementArgs, - ...placementArgs, - } - : defaultPlacementArgs; - - const { newPanelPlacement, otherPanels } = placementMethod - ? placementMethod(finalPlacementArgs as TPlacementMethodArgs) - : findTopLeftMostOpenSpace(defaultPlacementArgs); - - return { - newPanel: { - gridData: { - ...newPanelPlacement, - i: panelState.explicitInput.id, - }, - ...panelState, - }, - otherPanels, - }; -} diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/index.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/index.ts new file mode 100644 index 0000000000000..8e7444712c281 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { placePanel } from './place_panel'; + +export { placeClonePanel } from './place_clone_panel_strategy'; diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_clone_panel_strategy.ts similarity index 53% rename from src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts rename to src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_clone_panel_strategy.ts index 829b26072f0d9..affe85dff5d26 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_clone_panel_strategy.ts @@ -6,103 +6,14 @@ * Side Public License, v 1. */ -import _ from 'lodash'; +import { cloneDeep, forOwn } from 'lodash'; import { PanelNotFoundError } from '@kbn/embeddable-plugin/public'; + import { DashboardPanelState } from '../../../../common'; import { GridData } from '../../../../common/content_management'; +import { PanelPlacementProps, PanelPlacementReturn } from './types'; import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../dashboard_constants'; -export type PanelPlacementMethod = ( - args: PlacementArgs -) => PanelPlacementMethodReturn; - -interface PanelPlacementMethodReturn { - newPanelPlacement: Omit; - otherPanels: { [key: string]: DashboardPanelState }; -} - -export interface IPanelPlacementArgs { - width: number; - height: number; - currentPanels: { [key: string]: DashboardPanelState }; - scrollToPanel?: boolean; -} - -export interface IPanelPlacementBesideArgs extends IPanelPlacementArgs { - placeBesideId: string; -} - -// Look for the smallest y and x value where the default panel will fit. -export function findTopLeftMostOpenSpace({ - width, - height, - currentPanels, -}: IPanelPlacementArgs): PanelPlacementMethodReturn { - let maxY = -1; - - const currentPanelsArray = Object.values(currentPanels); - currentPanelsArray.forEach((panel) => { - maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY); - }); - - // Handle case of empty grid. - if (maxY < 0) { - return { newPanelPlacement: { x: 0, y: 0, w: width, h: height }, otherPanels: currentPanels }; - } - - const grid = new Array(maxY); - for (let y = 0; y < maxY; y++) { - grid[y] = new Array(DASHBOARD_GRID_COLUMN_COUNT).fill(0); - } - - currentPanelsArray.forEach((panel) => { - for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) { - for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) { - const row = grid[y]; - if (row === undefined) { - throw new Error( - `Attempted to access a row that doesn't exist at ${y} for panel ${JSON.stringify( - panel - )}` - ); - } - grid[y][x] = 1; - } - } - }); - - for (let y = 0; y < maxY; y++) { - for (let x = 0; x < DASHBOARD_GRID_COLUMN_COUNT; x++) { - if (grid[y][x] === 1) { - // Space is filled - continue; - } else { - for (let h = y; h < Math.min(y + height, maxY); h++) { - for (let w = x; w < Math.min(x + width, DASHBOARD_GRID_COLUMN_COUNT); w++) { - const spaceIsEmpty = grid[h][w] === 0; - const fitsPanelWidth = w === x + width - 1; - // If the panel is taller than any other panel in the current grid, it can still fit in the space, hence - // we check the minimum of maxY and the panel height. - const fitsPanelHeight = h === Math.min(y + height - 1, maxY - 1); - - if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) { - // Found space - return { - newPanelPlacement: { x, y, w: width, h: height }, - otherPanels: currentPanels, - }; - } else if (grid[h][w] === 1) { - // x, y spot doesn't work, break. - break; - } - } - } - } - } - } - return { newPanelPlacement: { x: 0, y: maxY, w: width, h: height }, otherPanels: currentPanels }; -} - interface IplacementDirection { grid: Omit; fits: boolean; @@ -128,19 +39,19 @@ function comparePanels(a: GridData, b: GridData): number { return 1; } -export function placePanelBeside({ +export function placeClonePanel({ width, height, currentPanels, placeBesideId, -}: IPanelPlacementBesideArgs): PanelPlacementMethodReturn { +}: PanelPlacementProps & { placeBesideId: string }): PanelPlacementReturn { const panelToPlaceBeside = currentPanels[placeBesideId]; if (!panelToPlaceBeside) { throw new PanelNotFoundError(); } const beside = panelToPlaceBeside.gridData; const otherPanelGridData: GridData[] = []; - _.forOwn(currentPanels, (panel: DashboardPanelState, key: string | undefined) => { + forOwn(currentPanels, (panel: DashboardPanelState, key: string | undefined) => { otherPanelGridData.push(panel.gridData); }); @@ -197,7 +108,7 @@ export function placePanelBeside({ for (let j = position + 1; j < grid.length; j++) { originalPositionInTheGrid = grid[j].i; - const movedPanel = _.cloneDeep(otherPanels[originalPositionInTheGrid]); + const movedPanel = cloneDeep(otherPanels[originalPositionInTheGrid]); movedPanel.gridData.y = movedPanel.gridData.y + diff; otherPanels[originalPositionInTheGrid] = movedPanel; } diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_new_panel_strategies.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_new_panel_strategies.ts new file mode 100644 index 0000000000000..8a8c8a83193eb --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_new_panel_strategies.ts @@ -0,0 +1,104 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../dashboard_constants'; +import { PanelPlacementProps, PanelPlacementReturn } from './types'; + +export const panelPlacementStrategies = { + // Place on the very top of the Dashboard, add the height of this panel to all other panels. + placeAtTop: ({ width, height, currentPanels }: PanelPlacementProps): PanelPlacementReturn => { + const otherPanels = { ...currentPanels }; + for (const [id, panel] of Object.entries(currentPanels)) { + const currentPanel = cloneDeep(panel); + currentPanel.gridData.y = currentPanel.gridData.y + height; + otherPanels[id] = currentPanel; + } + return { + newPanelPlacement: { x: 0, y: 0, w: width, h: height }, + otherPanels, + }; + }, + + // Look for the smallest y and x value where the default panel will fit. + findTopLeftMostOpenSpace: ({ + width, + height, + currentPanels, + }: PanelPlacementProps): PanelPlacementReturn => { + let maxY = -1; + + const currentPanelsArray = Object.values(currentPanels); + currentPanelsArray.forEach((panel) => { + maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY); + }); + + // Handle case of empty grid. + if (maxY < 0) { + return { + newPanelPlacement: { x: 0, y: 0, w: width, h: height }, + otherPanels: currentPanels, + }; + } + + const grid = new Array(maxY); + for (let y = 0; y < maxY; y++) { + grid[y] = new Array(DASHBOARD_GRID_COLUMN_COUNT).fill(0); + } + + currentPanelsArray.forEach((panel) => { + for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) { + for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) { + const row = grid[y]; + if (row === undefined) { + throw new Error( + `Attempted to access a row that doesn't exist at ${y} for panel ${JSON.stringify( + panel + )}` + ); + } + grid[y][x] = 1; + } + } + }); + + for (let y = 0; y < maxY; y++) { + for (let x = 0; x < DASHBOARD_GRID_COLUMN_COUNT; x++) { + if (grid[y][x] === 1) { + // Space is filled + continue; + } else { + for (let h = y; h < Math.min(y + height, maxY); h++) { + for (let w = x; w < Math.min(x + width, DASHBOARD_GRID_COLUMN_COUNT); w++) { + const spaceIsEmpty = grid[h][w] === 0; + const fitsPanelWidth = w === x + width - 1; + // If the panel is taller than any other panel in the current grid, it can still fit in the space, hence + // we check the minimum of maxY and the panel height. + const fitsPanelHeight = h === Math.min(y + height - 1, maxY - 1); + + if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) { + // Found space + return { + newPanelPlacement: { x, y, w: width, h: height }, + otherPanels: currentPanels, + }; + } else if (grid[h][w] === 1) { + // x, y spot doesn't work, break. + break; + } + } + } + } + } + } + return { + newPanelPlacement: { x: 0, y: maxY, w: width, h: height }, + otherPanels: currentPanels, + }; + }, +} as const; diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.test.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.test.ts new file mode 100644 index 0000000000000..24023ba92dbce --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.test.ts @@ -0,0 +1,167 @@ +/* + * 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 { DashboardPanelState } from '../../../../common'; +import { EmbeddableFactory, EmbeddableInput } from '@kbn/embeddable-plugin/public'; +import { CONTACT_CARD_EMBEDDABLE } from '@kbn/embeddable-plugin/public/lib/test_samples'; +import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants'; + +import { placePanel } from './place_panel'; +import { IProvidesPanelPlacementSettings } from './types'; + +interface TestInput extends EmbeddableInput { + test: string; +} +const panels: { [key: string]: DashboardPanelState } = {}; + +test('adds a new panel state in 0,0 position', () => { + const { newPanel: panelState } = placePanel( + {} as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'hi', id: '123' }, + }, + panels + ); + expect(panelState.explicitInput.test).toBe('hi'); + expect(panelState.type).toBe(CONTACT_CARD_EMBEDDABLE); + expect(panelState.explicitInput.id).toBeDefined(); + expect(panelState.gridData.x).toBe(0); + expect(panelState.gridData.y).toBe(0); + expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); + + panels[panelState.explicitInput.id] = panelState; +}); + +test('adds a second new panel state', () => { + const { newPanel: panelState } = placePanel( + {} as unknown as EmbeddableFactory, + { type: CONTACT_CARD_EMBEDDABLE, explicitInput: { test: 'bye', id: '456' } }, + panels + ); + + expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH); + expect(panelState.gridData.y).toBe(0); + expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); + + panels[panelState.explicitInput.id] = panelState; +}); + +test('adds a third new panel state', () => { + const { newPanel: panelState } = placePanel( + {} as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'bye', id: '789' }, + }, + panels + ); + expect(panelState.gridData.x).toBe(0); + expect(panelState.gridData.y).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); + + panels[panelState.explicitInput.id] = panelState; +}); + +test('adds a new panel state in the top most position when it is open', () => { + // deleting panel 456 means that the top leftmost open position will be at the top of the Dashboard. + delete panels['456']; + const { newPanel: panelState } = placePanel( + {} as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'bye', id: '987' }, + }, + panels + ); + expect(panelState.gridData.x).toBe(DEFAULT_PANEL_WIDTH); + expect(panelState.gridData.y).toBe(0); + expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); + + // replace the topmost panel. + panels[panelState.explicitInput.id] = panelState; +}); + +test('adds a new panel state at the very top of the Dashboard with default sizing', () => { + const embeddableFactoryStub: IProvidesPanelPlacementSettings = { + getPanelPlacementSettings: jest.fn().mockImplementation(() => { + return { strategy: 'placeAtTop' }; + }), + }; + + const { newPanel: panelState } = placePanel( + embeddableFactoryStub as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'wowee', id: '9001' }, + }, + panels + ); + expect(panelState.gridData.x).toBe(0); + expect(panelState.gridData.y).toBe(0); + expect(panelState.gridData.h).toBe(DEFAULT_PANEL_HEIGHT); + expect(panelState.gridData.w).toBe(DEFAULT_PANEL_WIDTH); + + expect(embeddableFactoryStub.getPanelPlacementSettings).toHaveBeenCalledWith( + { id: '9001', test: 'wowee' }, + undefined + ); +}); + +test('adds a new panel state at the very top of the Dashboard with custom sizing', () => { + const embeddableFactoryStub: IProvidesPanelPlacementSettings = { + getPanelPlacementSettings: jest.fn().mockImplementation(() => { + return { strategy: 'placeAtTop', width: 10, height: 5 }; + }), + }; + + const { newPanel: panelState } = placePanel( + embeddableFactoryStub as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'woweee', id: '9002' }, + }, + panels + ); + expect(panelState.gridData.x).toBe(0); + expect(panelState.gridData.y).toBe(0); + expect(panelState.gridData.h).toBe(5); + expect(panelState.gridData.w).toBe(10); + + expect(embeddableFactoryStub.getPanelPlacementSettings).toHaveBeenCalledWith( + { id: '9002', test: 'woweee' }, + undefined + ); +}); + +test('passes through given attributes', () => { + const embeddableFactoryStub: IProvidesPanelPlacementSettings = { + getPanelPlacementSettings: jest.fn().mockImplementation(() => { + return { strategy: 'placeAtTop', width: 10, height: 5 }; + }), + }; + + placePanel( + embeddableFactoryStub as unknown as EmbeddableFactory, + { + type: CONTACT_CARD_EMBEDDABLE, + explicitInput: { test: 'wow', id: '9004' }, + }, + panels, + { testAttr: 'hello' } + ); + + expect(embeddableFactoryStub.getPanelPlacementSettings).toHaveBeenCalledWith( + { id: '9004', test: 'wow' }, + { testAttr: 'hello' } + ); +}); diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.ts new file mode 100644 index 0000000000000..a65c4fca9c115 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/place_panel.ts @@ -0,0 +1,60 @@ +/* + * 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 { PanelState, EmbeddableInput, EmbeddableFactory } from '@kbn/embeddable-plugin/public'; + +import { DashboardPanelState } from '../../../../common'; +import { panelPlacementStrategies } from './place_new_panel_strategies'; +import { IProvidesPanelPlacementSettings, PanelPlacementSettings } from './types'; +import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants'; + +export const providesPanelPlacementSettings = ( + value: unknown +): value is IProvidesPanelPlacementSettings => { + return Boolean((value as IProvidesPanelPlacementSettings).getPanelPlacementSettings); +}; + +export function placePanel( + factory: EmbeddableFactory, + newPanel: PanelState, + currentPanels: { [key: string]: DashboardPanelState }, + attributes?: unknown +): { + newPanel: DashboardPanelState; + otherPanels: { [key: string]: DashboardPanelState }; +} { + let placementSettings: PanelPlacementSettings = { + width: DEFAULT_PANEL_WIDTH, + height: DEFAULT_PANEL_HEIGHT, + strategy: 'findTopLeftMostOpenSpace', + }; + if (providesPanelPlacementSettings(factory)) { + placementSettings = { + ...placementSettings, + ...factory.getPanelPlacementSettings(newPanel.explicitInput, attributes), + }; + } + const { width, height, strategy } = placementSettings; + + const { newPanelPlacement, otherPanels } = panelPlacementStrategies[strategy]({ + currentPanels, + height, + width, + }); + + return { + newPanel: { + gridData: { + ...newPanelPlacement, + i: newPanel.explicitInput.id, + }, + ...newPanel, + }, + otherPanels, + }; +} diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel_placement/types.ts b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/types.ts new file mode 100644 index 0000000000000..7fb20b469c1a9 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/panel_placement/types.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EmbeddableInput } from '@kbn/embeddable-plugin/public'; +import { DashboardPanelState } from '../../../../common'; +import { GridData } from '../../../../common/content_management'; +import { panelPlacementStrategies } from './place_new_panel_strategies'; + +export type PanelPlacementStrategy = keyof typeof panelPlacementStrategies; + +export interface PanelPlacementSettings { + strategy: PanelPlacementStrategy; + height: number; + width: number; +} + +export interface PanelPlacementReturn { + newPanelPlacement: Omit; + otherPanels: { [key: string]: DashboardPanelState }; +} + +export interface PanelPlacementProps { + width: number; + height: number; + currentPanels: { [key: string]: DashboardPanelState }; +} + +export interface IProvidesPanelPlacementSettings< + InputType extends EmbeddableInput = EmbeddableInput, + AttributesType = unknown +> { + getPanelPlacementSettings: ( + input: InputType, + attributes?: AttributesType + ) => Partial; +} diff --git a/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx index d1931d7ed3e61..e37f14ee5b977 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx @@ -86,7 +86,9 @@ export const DashboardViewportComponent = () => { data-description={description} data-shared-items-count={panelCount} > - + {/* Wait for `viewportWidth` to actually be set before rendering the dashboard grid - + otherwise, there is a race condition where the panels can end up being squashed */} + {viewportWidth !== 0 && } ); 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 848600a2767d6..2a5662387c477 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts @@ -12,7 +12,6 @@ import { IEmbeddable, PanelState, } from '@kbn/embeddable-plugin/public'; -import { v4 as uuidv4 } from 'uuid'; import { DashboardPanelState } from '../../../../common'; import { DashboardContainer } from '../dashboard_container'; @@ -41,45 +40,14 @@ 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/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index 70e81ca7a76d1..e843d07ad6ff1 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -25,9 +25,9 @@ import { pluginServices } from '../../../services/plugin_services'; import { DashboardCreationOptions } from '../dashboard_container_factory'; import { DashboardContainerInput, DashboardPanelState } from '../../../../common'; import { startSyncingDashboardDataViews } from './data_views/sync_dashboard_data_views'; -import { findTopLeftMostOpenSpace } from '../../component/panel/dashboard_panel_placement'; import { LoadDashboardReturn } from '../../../services/dashboard_content_management/types'; import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_search_state'; +import { panelPlacementStrategies } from '../../component/panel_placement/place_new_panel_strategies'; import { DEFAULT_DASHBOARD_INPUT, DEFAULT_PANEL_HEIGHT, @@ -297,6 +297,7 @@ export const initializeDashboard = async ({ const { width, height } = incomingEmbeddable.size; const currentPanels = container.getInput().panels; const embeddableId = incomingEmbeddable.embeddableId ?? v4(); + const { findTopLeftMostOpenSpace } = panelPlacementStrategies; const { newPanelPlacement } = findTopLeftMostOpenSpace({ width: width ?? DEFAULT_PANEL_WIDTH, height: height ?? DEFAULT_PANEL_HEIGHT, diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx index 59c16a26d0402..651d71c106ced 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx @@ -43,11 +43,13 @@ pluginServices.getServices().embeddable.getEmbeddableFactory = jest test('DashboardContainer initializes embeddables', (done) => { const container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); @@ -94,11 +96,13 @@ test('DashboardContainer.replacePanel', (done) => { const ID = '123'; const container = buildMockDashboard({ - panels: { - [ID]: getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: ID }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + [ID]: getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: ID }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); let counter = 0; @@ -134,11 +138,13 @@ test('DashboardContainer.replacePanel', (done) => { test('Container view mode change propagates to existing children', async () => { const container = buildMockDashboard({ - panels: { - '123': getSampleDashboardPanel({ - explicitInput: { firstName: 'Sam', id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }), + overrides: { + panels: { + '123': getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, }, }); @@ -197,7 +203,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { uiActionsSetup.registerAction(editModeAction); uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); - const container = buildMockDashboard({ viewMode: ViewMode.VIEW }); + const container = buildMockDashboard({ overrides: { viewMode: ViewMode.VIEW } }); const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, @@ -273,8 +279,10 @@ describe('getInheritedInput', () => { test('Should pass dashboard timeRange and timeslice to panel when panel does not have custom time range', async () => { const container = buildMockDashboard({ - timeRange: dashboardTimeRange, - timeslice: dashboardTimeslice, + overrides: { + timeRange: dashboardTimeRange, + timeslice: dashboardTimeslice, + }, }); const embeddable = await container.addNewEmbeddable( CONTACT_CARD_EMBEDDABLE, @@ -296,8 +304,10 @@ describe('getInheritedInput', () => { test('Should not pass dashboard timeRange and timeslice to panel when panel has custom time range', async () => { const container = buildMockDashboard({ - timeRange: dashboardTimeRange, - timeslice: dashboardTimeslice, + overrides: { + timeRange: dashboardTimeRange, + timeslice: dashboardTimeslice, + }, }); const embeddableTimeRange = { to: 'now', 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 2a618ad6a04d4..38c5c8b077979 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -50,7 +50,7 @@ import { DashboardRenderPerformanceStats, } from '../types'; import { DASHBOARD_CONTAINER_TYPE } from '../..'; -import { createPanelState } from '../component/panel'; +import { placePanel } from '../component/panel_placement'; import { pluginServices } from '../../services/plugin_services'; import { initializeDashboard } from './create/create_dashboard'; import { DASHBOARD_LOADED_EVENT } from '../../dashboard_constants'; @@ -213,11 +213,14 @@ export class DashboardContainer extends Container >( factory: EmbeddableFactory, - partial: Partial = {} - ): DashboardPanelState { - const panelState = super.createNewPanelState(factory, partial); - const { newPanel } = createPanelState(panelState, this.input.panels); - return newPanel; + partial: Partial = {}, + attributes?: unknown + ): { + newPanel: DashboardPanelState; + otherPanels: DashboardContainerInput['panels']; + } { + const { newPanel } = super.createNewPanelState(factory, partial, attributes); + return placePanel(factory, newPanel, this.input.panels, attributes); } public render(dom: HTMLElement) { diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 0bfe5dfae8b4c..6882090df441a 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -13,6 +13,7 @@ export { createDashboardEditUrl, DASHBOARD_APP_ID, LEGACY_DASHBOARD_APP_ID, + DASHBOARD_GRID_COLUMN_COUNT, } from './dashboard_constants'; export { type DashboardAPI, @@ -30,6 +31,7 @@ export { type DashboardAppLocatorParams, cleanEmptyKeys, } from './dashboard_app/locator/locator'; +export { getEmbeddableParams } from './dashboard_app/locator/get_dashboard_locator_params'; export function plugin(initializerContext: PluginInitializerContext) { return new DashboardPlugin(initializerContext); diff --git a/src/plugins/dashboard/public/mocks.tsx b/src/plugins/dashboard/public/mocks.tsx index c081a3ab276b8..7611e12b90ae4 100644 --- a/src/plugins/dashboard/public/mocks.tsx +++ b/src/plugins/dashboard/public/mocks.tsx @@ -66,7 +66,13 @@ export function setupIntersectionObserverMock({ }); } -export function buildMockDashboard(overrides?: Partial) { +export function buildMockDashboard({ + overrides, + savedObjectId, +}: { + overrides?: Partial; + savedObjectId?: string; +} = {}) { const initialInput = getSampleDashboardInput(overrides); const dashboardContainer = new DashboardContainer( initialInput, @@ -75,7 +81,7 @@ export function buildMockDashboard(overrides?: Partial) undefined, undefined, undefined, - { lastSavedInput: initialInput } + { lastSavedInput: initialInput, lastSavedId: savedObjectId } ); return dashboardContainer; } diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts index 027e1d0e7d47d..bb63c01b0e2d7 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management.stub.ts @@ -44,6 +44,7 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen hits, }); }), + findById: jest.fn(), findByIds: jest.fn().mockImplementation(() => Promise.resolve([ { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_cache.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_cache.ts new file mode 100644 index 0000000000000..20b9e5a9cb2a7 --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_cache.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import LRUCache from 'lru-cache'; +import { DashboardCrudTypes } from '../../../common/content_management'; +import { DASHBOARD_CACHE_SIZE, DASHBOARD_CACHE_TTL } from '../../dashboard_constants'; + +export class DashboardContentManagementCache { + private cache: LRUCache; + + constructor() { + this.cache = new LRUCache({ + max: DASHBOARD_CACHE_SIZE, + maxAge: DASHBOARD_CACHE_TTL, + }); + } + + /** Fetch the dashboard with `id` from the cache */ + public fetchDashboard(id: string) { + return this.cache.get(id); + } + + /** Add the fetched dashboard to the cache */ + public addDashboard({ item: dashboard, meta }: DashboardCrudTypes['GetOut']) { + this.cache.set(dashboard.id, { + meta, + item: dashboard, + }); + } + + /** Delete the dashboard with `id` from the cache */ + public deleteDashboard(id: string) { + this.cache.del(id); + } +} diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts index 08e7176e19059..b3689b9be4238 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts @@ -13,6 +13,7 @@ import { checkForDuplicateDashboardTitle } from './lib/check_for_duplicate_dashb import { searchDashboards, + findDashboardById, findDashboardsByIds, findDashboardIdByTitle, } from './lib/find_dashboards'; @@ -21,9 +22,10 @@ import type { DashboardContentManagementRequiredServices, DashboardContentManagementService, } from './types'; -import { loadDashboardState } from './lib/load_dashboard_state'; import { deleteDashboards } from './lib/delete_dashboards'; +import { loadDashboardState } from './lib/load_dashboard_state'; import { updateDashboardMeta } from './lib/update_dashboard_meta'; +import { DashboardContentManagementCache } from './dashboard_content_management_cache'; export type DashboardContentManagementServiceFactory = KibanaPluginServiceFactory< DashboardContentManagementService, @@ -31,6 +33,8 @@ export type DashboardContentManagementServiceFactory = KibanaPluginServiceFactor DashboardContentManagementRequiredServices >; +export const dashboardContentManagementCache = new DashboardContentManagementCache(); + export const dashboardContentManagementServiceFactory: DashboardContentManagementServiceFactory = ( { startPlugins: { contentManagement } }, requiredServices @@ -66,14 +70,16 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen dashboardSessionStorage, }), findDashboards: { - search: ({ hasReference, hasNoReference, search, size }) => + search: ({ hasReference, hasNoReference, search, size, options }) => searchDashboards({ contentManagement, hasNoReference, hasReference, + options, search, size, }), + findById: (id) => findDashboardById(contentManagement, id), findByIds: (ids) => findDashboardsByIds(contentManagement, ids), findByTitle: (title) => findDashboardIdByTitle(contentManagement, title), }, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts index e18841eacfcfd..cf861a02b45e9 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/delete_dashboards.ts @@ -9,20 +9,22 @@ import { DashboardStartDependencies } from '../../../plugin'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; import { DashboardCrudTypes } from '../../../../common/content_management'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; export const deleteDashboards = async ( ids: string[], contentManagement: DashboardStartDependencies['contentManagement'] ) => { - const deletePromises = ids.map((id) => - contentManagement.client.delete< + const deletePromises = ids.map((id) => { + dashboardContentManagementCache.deleteDashboard(id); + return contentManagement.client.delete< DashboardCrudTypes['DeleteIn'], DashboardCrudTypes['DeleteOut'] >({ contentTypeId: DASHBOARD_CONTENT_ID, id, - }) - ); + }); + }); await Promise.all(deletePromises); }; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts index 2b2f6ac4804f7..efeaa76297f9e 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts @@ -16,9 +16,11 @@ import { } from '../../../../common/content_management'; import { DashboardStartDependencies } from '../../../plugin'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; export interface SearchDashboardsArgs { contentManagement: DashboardStartDependencies['contentManagement']; + options?: DashboardCrudTypes['SearchIn']['options']; hasNoReference?: SavedObjectsFindOptionsReference[]; hasReference?: SavedObjectsFindOptionsReference[]; search: string; @@ -34,6 +36,7 @@ export async function searchDashboards({ contentManagement, hasNoReference, hasReference, + options, search, size, }: SearchDashboardsArgs): Promise { @@ -53,6 +56,7 @@ export async function searchDashboards({ excluded: (hasNoReference ?? []).map(({ id }) => id), }, }, + options, }); return { total, @@ -65,23 +69,42 @@ export type FindDashboardsByIdResponse = { id: string } & ( | { status: 'error'; error: SavedObjectError } ); -export async function findDashboardsByIds( +export async function findDashboardById( contentManagement: DashboardStartDependencies['contentManagement'], - ids: string[] -): Promise { - const findPromises = ids.map((id) => - contentManagement.client.get({ + id: string +): Promise { + /** If the dashboard exists in the cache, then return the result from that */ + const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id); + if (cachedDashboard) { + return { + id, + status: 'success', + attributes: cachedDashboard.item.attributes, + references: cachedDashboard.item.references, + }; + } + /** Otherwise, fetch the dashboard from the content management client, add it to the cache, and return the result */ + const response = await contentManagement.client + .get({ contentTypeId: DASHBOARD_CONTENT_ID, id, }) - ); - const results = await Promise.all(findPromises); + .then((result) => { + dashboardContentManagementCache.addDashboard(result); + return { id, status: 'success', attributes: result.item.attributes }; + }) + .catch((e) => ({ status: 'error', error: e.body, id })); - return results.map((result) => { - if (result.item.error) return { status: 'error', error: result.item.error, id: result.item.id }; - const { attributes, id, references } = result.item; - return { id, status: 'success', attributes, references }; - }); + return response as FindDashboardsByIdResponse; +} + +export async function findDashboardsByIds( + contentManagement: DashboardStartDependencies['contentManagement'], + ids: string[] +): Promise { + const findPromises = ids.map((id) => findDashboardById(contentManagement, id)); + const results = await Promise.all(findPromises); + return results as FindDashboardsByIdResponse[]; } export async function findDashboardIdByTitle( diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts index 93034531a791b..21ab8c8143d68 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts @@ -21,10 +21,11 @@ import { convertSavedPanelsToPanelMap, } from '../../../../common'; import { migrateDashboardInput } from './migrate_dashboard_input'; +import { convertNumberToDashboardVersion } from './dashboard_versioning'; import { DashboardCrudTypes } from '../../../../common/content_management'; import type { LoadDashboardFromSavedObjectProps, LoadDashboardReturn } from '../types'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; import { DASHBOARD_CONTENT_ID, DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants'; -import { convertNumberToDashboardVersion } from './dashboard_versioning'; export function migrateLegacyQuery(query: Query | { [key: string]: any } | string): Query { // Lucene was the only option before, so language-less queries are all lucene @@ -60,14 +61,27 @@ export const loadDashboardState = async ({ /** * Load the saved object from Content Management */ - const { item: rawDashboardContent, meta: resolveMeta } = await contentManagement.client - .get({ - contentTypeId: DASHBOARD_CONTENT_ID, - id, - }) - .catch((e) => { - throw new SavedObjectNotFound(DASHBOARD_CONTENT_ID, id); - }); + let rawDashboardContent; + let resolveMeta; + + const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id); + if (cachedDashboard) { + /** If the dashboard exists in the cache, use the cached version to load the dashboard */ + ({ item: rawDashboardContent, meta: resolveMeta } = cachedDashboard); + } else { + /** Otherwise, fetch and load the dashboard from the content management client, and add it to the cache */ + const result = await contentManagement.client + .get({ + contentTypeId: DASHBOARD_CONTENT_ID, + id, + }) + .catch((e) => { + throw new SavedObjectNotFound(DASHBOARD_CONTENT_ID, id); + }); + + dashboardContentManagementCache.addDashboard(result); + ({ item: rawDashboardContent, meta: resolveMeta } = result); + } if (!rawDashboardContent || !rawDashboardContent.version) { return { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts index 7f0ed1e8305cd..eac769f03de6c 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts @@ -29,10 +29,11 @@ import { } from '../types'; import { DashboardStartDependencies } from '../../../plugin'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; +import { convertDashboardVersionToNumber } from './dashboard_versioning'; import { LATEST_DASHBOARD_CONTAINER_VERSION } from '../../../dashboard_container'; +import { dashboardContentManagementCache } from '../dashboard_content_management_service'; import { DashboardCrudTypes, DashboardAttributes } from '../../../../common/content_management'; import { dashboardSaveToastStrings } from '../../../dashboard_container/_dashboard_container_strings'; -import { convertDashboardVersionToNumber } from './dashboard_versioning'; export const serializeControlGroupInput = ( controlGroupInput: DashboardContainerInput['controlGroupInput'] @@ -203,6 +204,8 @@ export const saveDashboardState = async ({ if (newId !== lastSavedId) { dashboardSessionStorage.clearState(lastSavedId); return { redirectRequired: true, id: newId }; + } else { + dashboardContentManagementCache.deleteDashboard(newId); // something changed in an existing dashboard, so delete it from the cache so that it can be re-fetched } } return { id: newId }; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts index 90a6bc49de25a..7eb9a0114bfec 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts @@ -92,8 +92,12 @@ export interface SaveDashboardReturn { */ export interface FindDashboardsService { search: ( - props: Pick + props: Pick< + SearchDashboardsArgs, + 'hasReference' | 'hasNoReference' | 'search' | 'size' | 'options' + > ) => Promise; + findById: (id: string) => Promise; findByIds: (ids: string[]) => Promise; findByTitle: (title: string) => Promise<{ id: string } | undefined>; } diff --git a/src/plugins/dashboard/public/services/mocks.ts b/src/plugins/dashboard/public/services/mocks.ts new file mode 100644 index 0000000000000..d695722102edb --- /dev/null +++ b/src/plugins/dashboard/public/services/mocks.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { pluginServices } from './plugin_services'; +import { registry } from './plugin_services.stub'; + +export function setStubDashboardServices() { + pluginServices.setRegistry(registry.start({})); +} + +setStubDashboardServices(); diff --git a/src/plugins/embeddable/public/add_panel_flyout/add_panel_flyout.tsx b/src/plugins/embeddable/public/add_panel_flyout/add_panel_flyout.tsx index 07765836057ff..f0554fed61782 100644 --- a/src/plugins/embeddable/public/add_panel_flyout/add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/add_panel_flyout/add_panel_flyout.tsx @@ -103,7 +103,8 @@ export const AddPanelFlyout = ({ const embeddable = await container.addNewEmbeddable( factoryForSavedObjectType.type, - { savedObjectId: id } + { savedObjectId: id }, + savedObject.attributes ); onAddPanel?.(embeddable.id); diff --git a/src/plugins/embeddable/public/embeddable_panel/panel_actions/customize_panel_action/customize_panel_action.tsx b/src/plugins/embeddable/public/embeddable_panel/panel_actions/customize_panel_action/customize_panel_action.tsx index c3b99ca8fd4b7..63fc1902102b6 100644 --- a/src/plugins/embeddable/public/embeddable_panel/panel_actions/customize_panel_action/customize_panel_action.tsx +++ b/src/plugins/embeddable/public/embeddable_panel/panel_actions/customize_panel_action/customize_panel_action.tsx @@ -75,9 +75,15 @@ export class CustomizePanelAction implements Action (embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'markdown'; const isImage = embeddable.type === 'image'; + const isNavigation = embeddable.type === 'navigation'; return Boolean( - embeddable && hasTimeRange(embeddable) && !isInputControl && !isMarkdown && !isImage + embeddable && + hasTimeRange(embeddable) && + !isInputControl && + !isMarkdown && + !isImage && + !isNavigation ); } diff --git a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts index 3f465f2d37439..fe55b9a39158b 100644 --- a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts +++ b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts @@ -18,6 +18,7 @@ import { EmbeddableInput, EmbeddableEditorState, EmbeddableStateTransfer, + isExplicitInputWithAttributes, } from '../../../lib'; import { ViewMode } from '../../../lib/types'; import { EmbeddableStart } from '../../../plugin'; @@ -95,7 +96,19 @@ export class EditPanelAction implements Action { } const oldExplicitInput = embeddable.getExplicitInput(); - const newExplicitInput = await factory.getExplicitInput(oldExplicitInput); + let newExplicitInput: Partial; + try { + const explicitInputReturn = await factory.getExplicitInput( + oldExplicitInput, + embeddable.parent + ); + newExplicitInput = isExplicitInputWithAttributes(explicitInputReturn) + ? explicitInputReturn.newInput + : explicitInputReturn; + } catch (e) { + // error likely means user canceled editing + return; + } embeddable.parent?.replaceEmbeddable(embeddable.id, newExplicitInput); return; } diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 91e6efcdc41c8..0e3650ea8a8a4 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -76,6 +76,7 @@ export { EmbeddableRenderer, useEmbeddableFactory, isFilterableEmbeddable, + isExplicitInputWithAttributes, shouldFetch$, shouldRefreshFilterCompareOptions, PANEL_HOVER_TRIGGER, diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index fceae56cf4b96..546c9a9a9bf7f 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -160,23 +160,32 @@ export abstract class Container< EEI extends EmbeddableInput = EmbeddableInput, EEO extends EmbeddableOutput = EmbeddableOutput, E extends IEmbeddable = IEmbeddable - >(type: string, explicitInput: Partial): Promise { + >(type: string, explicitInput: Partial, attributes?: unknown): Promise { const factory = this.getFactory(type) as EmbeddableFactory | undefined; if (!factory) { throw new EmbeddableFactoryNotFoundError(type); } - const panelState = this.createNewPanelState(factory, explicitInput); + const { newPanel, otherPanels } = this.createNewPanelState( + factory, + explicitInput, + attributes + ); - return this.createAndSaveEmbeddable(type, panelState); + return this.createAndSaveEmbeddable(type, newPanel, otherPanels); } public async replaceEmbeddable< 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 +195,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) { @@ -301,7 +317,7 @@ export abstract class Container< public async getExplicitInputIsEqual(lastInput: TContainerInput) { const { panels: lastPanels, ...restOfLastInput } = lastInput; - const { panels: currentPanels, ...restOfCurrentInput } = this.getInput(); + const { panels: currentPanels, ...restOfCurrentInput } = this.getExplicitInput(); const otherInputIsEqual = isEqual(restOfLastInput, restOfCurrentInput); if (!otherInputIsEqual) return false; @@ -330,8 +346,9 @@ export abstract class Container< TEmbeddable extends IEmbeddable >( factory: EmbeddableFactory, - partial: Partial = {} - ): PanelState { + partial: Partial = {}, + attributes?: unknown + ): { newPanel: PanelState; otherPanels: TContainerInput['panels'] } { const embeddableId = partial.id || uuidv4(); const explicitInput = this.createNewExplicitEmbeddableInput( @@ -341,12 +358,15 @@ export abstract class Container< ); return { - type: factory.type, - explicitInput: { - ...explicitInput, - id: embeddableId, - version: factory.latestVersion, - } as TEmbeddableInput, + newPanel: { + type: factory.type, + explicitInput: { + ...explicitInput, + id: embeddableId, + version: factory.latestVersion, + } as TEmbeddableInput, + }, + otherPanels: this.getInput().panels, }; } @@ -372,7 +392,6 @@ export abstract class Container< initializeSettings?: EmbeddableContainerSettings ) { let initializeOrder = Object.keys(initialInput.panels); - if (initializeSettings?.childIdInitializeOrder) { const initializeOrderSet = new Set(); @@ -401,10 +420,10 @@ export abstract class Container< protected async createAndSaveEmbeddable< TEmbeddableInput extends EmbeddableInput = EmbeddableInput, TEmbeddable extends IEmbeddable = IEmbeddable - >(type: string, panelState: PanelState) { + >(type: string, panelState: PanelState, otherPanels: TContainerInput['panels']) { this.updateInput({ panels: { - ...this.input.panels, + ...otherPanels, [panelState.explicitInput.id]: panelState, }, } as Partial); diff --git a/src/plugins/embeddable/public/lib/containers/i_container.ts b/src/plugins/embeddable/public/lib/containers/i_container.ts index 5539f854b24d9..53226e7d15146 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -96,7 +96,8 @@ export interface IContainer< E extends Embeddable = Embeddable >( type: string, - explicitInput: Partial + explicitInput: Partial, + attributes?: unknown ): Promise; replaceEmbeddable< @@ -106,6 +107,7 @@ export interface IContainer< >( id: string, newExplicitInput: Partial, - newType?: string - ): void; + newType?: string, + generateNewId?: boolean + ): Promise; } diff --git a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts index 472840208e139..50555601d4bca 100644 --- a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts +++ b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts @@ -30,6 +30,7 @@ export const defaultEmbeddableFactoryProvider = < } const factory: EmbeddableFactory = { + ...def, latestVersion: def.latestVersion, isContainerType: def.isContainerType ?? false, canCreateNew: def.canCreateNew ? def.canCreateNew.bind(def) : () => true, diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts index 8f6b51ec41b23..a96287a61d0f3 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts @@ -24,6 +24,17 @@ export interface OutputSpec { [key: string]: PropertySpec; } +export interface ExplicitInputWithAttributes { + newInput: Partial; + attributes?: unknown; +} + +export const isExplicitInputWithAttributes = ( + value: ExplicitInputWithAttributes | Partial +): value is ExplicitInputWithAttributes => { + return Boolean((value as ExplicitInputWithAttributes).newInput); +}; + /** * EmbeddableFactories create and initialize an embeddable instance */ @@ -106,8 +117,14 @@ export interface EmbeddableFactory< * input passed down from the parent container. * * Can be used to edit an embeddable by re-requesting explicit input. Initial input can be provided to allow the editor to show the current state. + * + * If saved object information is needed for creation use-cases, getExplicitInput can also return an unknown typed attributes object which will be passed + * into the container's addNewEmbeddable function. */ - getExplicitInput(initialInput?: Partial): Promise>; + getExplicitInput( + initialInput?: Partial, + parent?: IContainer + ): Promise | ExplicitInputWithAttributes>; /** * Creates a new embeddable instance based off the saved object id. diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index f17f1e1cc42e8..0f3db5fc2bbab 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -493,6 +493,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'labs:dashboard:linksPanel': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'discover:showFieldStatistics': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 902190f0cf675..fb3c31bf44d89 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -135,6 +135,7 @@ export interface UsageStats { 'labs:canvas:useDataService': boolean; 'labs:presentation:timeToPresent': boolean; 'labs:dashboard:enable_ui': boolean; + 'labs:dashboard:linksPanel': boolean; 'labs:dashboard:deferBelowFold': boolean; 'labs:dashboard:dashboardControls': boolean; 'discover:rowHeightOption': number; diff --git a/src/plugins/links/README.md b/src/plugins/links/README.md new file mode 100644 index 0000000000000..f2e37b203902b --- /dev/null +++ b/src/plugins/links/README.md @@ -0,0 +1,3 @@ +# Links panel + +This plugin adds the Links panel which allows authors to create hard links to navigate on click and bring all context from the source dashboard to the destination dashboard. diff --git a/src/plugins/links/common/constants.ts b/src/plugins/links/common/constants.ts new file mode 100644 index 0000000000000..eeba785bf21cd --- /dev/null +++ b/src/plugins/links/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 = 'links'; + +export const APP_ICON = 'link'; + +export const APP_NAME = i18n.translate('links.visTypeAlias.title', { + defaultMessage: 'Links', +}); diff --git a/src/plugins/links/common/content_management/cm_services.ts b/src/plugins/links/common/content_management/cm_services.ts new file mode 100644 index 0000000000000..8767b7badd9bd --- /dev/null +++ b/src/plugins/links/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 index file 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/links/common/content_management/index.ts b/src/plugins/links/common/content_management/index.ts new file mode 100644 index 0000000000000..e2aa69ec32e4f --- /dev/null +++ b/src/plugins/links/common/content_management/index.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. + */ + +export { LATEST_VERSION, CONTENT_ID } from '../constants'; + +export type { LinksContentType } from '../types'; + +export type { + LinkType, + LinksLayoutType, + LinkOptions, + Link, + LinksItem, + LinksCrudTypes, + LinksAttributes, +} from './latest'; + +export { + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + LINKS_VERTICAL_LAYOUT, + LINKS_HORIZONTAL_LAYOUT, +} from './latest'; + +export * as LinksV1 from './v1'; diff --git a/src/plugins/links/common/content_management/latest.ts b/src/plugins/links/common/content_management/latest.ts new file mode 100644 index 0000000000000..e9c79f0f50f93 --- /dev/null +++ b/src/plugins/links/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/links/common/content_management/v1/cm_services.ts b/src/plugins/links/common/content_management/v1/cm_services.ts new file mode 100644 index 0000000000000..597fcfe0d8451 --- /dev/null +++ b/src/plugins/links/common/content_management/v1/cm_services.ts @@ -0,0 +1,139 @@ +/* + * 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, + createResultSchema, + updateOptionsSchema, + createOptionsSchemas, + objectTypeToGetResultSchema, +} from '@kbn/content-management-utils'; +import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '.'; +import { LINKS_HORIZONTAL_LAYOUT, LINKS_VERTICAL_LAYOUT } from './constants'; + +const baseLinkSchema = { + id: schema.string(), + label: schema.maybe(schema.string()), + order: schema.number(), +}; + +const dashboardLinkSchema = schema.object({ + ...baseLinkSchema, + destinationRefName: schema.string(), + type: schema.literal(DASHBOARD_LINK_TYPE), + options: schema.maybe( + schema.object( + { + openInNewTab: schema.boolean(), + useCurrentFilters: schema.boolean(), + useCurrentDateRange: schema.boolean(), + }, + { unknowns: 'forbid' } + ) + ), +}); + +const externalLinkSchema = schema.object({ + ...baseLinkSchema, + type: schema.literal(EXTERNAL_LINK_TYPE), + destination: schema.string(), + options: schema.maybe( + schema.object( + { + openInNewTab: schema.boolean(), + encodeUrl: schema.boolean(), + }, + { unknowns: 'forbid' } + ) + ), +}); + +const linksAttributesSchema = schema.object( + { + title: schema.string(), + description: schema.maybe(schema.string()), + links: schema.arrayOf(schema.oneOf([dashboardLinkSchema, externalLinkSchema])), + layout: schema.maybe( + schema.oneOf([schema.literal(LINKS_HORIZONTAL_LAYOUT), schema.literal(LINKS_VERTICAL_LAYOUT)]) + ), + }, + { unknowns: 'forbid' } +); + +const linksSavedObjectSchema = savedObjectSchema(linksAttributesSchema); + +const searchOptionsSchema = schema.maybe( + schema.object( + { + onlyTitle: schema.maybe(schema.boolean()), + }, + { unknowns: 'forbid' } + ) +); + +const linksCreateOptionsSchema = schema.object({ + references: schema.maybe(createOptionsSchemas.references), + overwrite: createOptionsSchemas.overwrite, +}); + +const linksUpdateOptionsSchema = 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(linksSavedObjectSchema), + }, + }, + }, + create: { + in: { + options: { + schema: linksCreateOptionsSchema, + }, + data: { + schema: linksAttributesSchema, + }, + }, + out: { + result: { + schema: createResultSchema(linksSavedObjectSchema), + }, + }, + }, + update: { + in: { + options: { + schema: linksUpdateOptionsSchema, // same schema as "create" + }, + data: { + schema: linksAttributesSchema, + }, + }, + }, + search: { + in: { + options: { + schema: searchOptionsSchema, + }, + }, + }, + mSearch: { + out: { + result: { + schema: linksSavedObjectSchema, + }, + }, + }, +}; diff --git a/src/plugins/links/common/content_management/v1/constants.ts b/src/plugins/links/common/content_management/v1/constants.ts new file mode 100644 index 0000000000000..f14fdbeaf28cb --- /dev/null +++ b/src/plugins/links/common/content_management/v1/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. + */ + +/** + * Link types + */ +export const DASHBOARD_LINK_TYPE = 'dashboardLink'; +export const EXTERNAL_LINK_TYPE = 'externalLink'; + +/** + * Layout options + */ +export const LINKS_HORIZONTAL_LAYOUT = 'horizontal'; +export const LINKS_VERTICAL_LAYOUT = 'vertical'; diff --git a/src/plugins/links/common/content_management/v1/index.ts b/src/plugins/links/common/content_management/v1/index.ts new file mode 100644 index 0000000000000..65738f89ff8a6 --- /dev/null +++ b/src/plugins/links/common/content_management/v1/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LinksCrudTypes } from './types'; +export type { + LinksCrudTypes, + LinksAttributes, + Link, + LinkOptions, + LinksLayoutType, + LinkType, +} from './types'; +export type LinksItem = LinksCrudTypes['Item']; +export { + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + LINKS_VERTICAL_LAYOUT, + LINKS_HORIZONTAL_LAYOUT, +} from './constants'; diff --git a/src/plugins/links/common/content_management/v1/types.ts b/src/plugins/links/common/content_management/v1/types.ts new file mode 100644 index 0000000000000..880bcbc67dd1d --- /dev/null +++ b/src/plugins/links/common/content_management/v1/types.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ContentManagementCrudTypes, + SavedObjectCreateOptions, + SavedObjectUpdateOptions, +} from '@kbn/content-management-utils'; +import { type UrlDrilldownOptions } from '@kbn/ui-actions-enhanced-plugin/public'; +import { type DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; + +import { LinksContentType } from '../../types'; +import { + DASHBOARD_LINK_TYPE, + EXTERNAL_LINK_TYPE, + LINKS_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, +} from './constants'; + +export type LinksCrudTypes = ContentManagementCrudTypes< + LinksContentType, + LinksAttributes, + Pick, + Pick, + { + /** Flag to indicate to only search the text on the "title" field */ + onlyTitle?: boolean; + } +>; + +export type LinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; + +export type LinkOptions = DashboardDrilldownOptions | UrlDrilldownOptions; +interface BaseLink { + id: string; + label?: string; + order: number; + options?: LinkOptions; + destination?: string; +} + +interface DashboardLink extends BaseLink { + type: typeof DASHBOARD_LINK_TYPE; + destinationRefName?: string; +} + +interface ExternalLink extends BaseLink { + type: typeof EXTERNAL_LINK_TYPE; + destination: string; +} + +export type Link = DashboardLink | ExternalLink; + +export type LinksLayoutType = typeof LINKS_HORIZONTAL_LAYOUT | typeof LINKS_VERTICAL_LAYOUT; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type LinksAttributes = { + 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 new file mode 100644 index 0000000000000..8653a3d650d70 --- /dev/null +++ b/src/plugins/links/common/embeddable/extract.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { extract } from './extract'; + +test('Should return original state and empty references with by-reference embeddable state', () => { + const 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 new file mode 100644 index 0000000000000..5fe842e4316b1 --- /dev/null +++ b/src/plugins/links/common/embeddable/extract.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; +import type { 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 new file mode 100644 index 0000000000000..c526b0bf9bff8 --- /dev/null +++ b/src/plugins/links/common/embeddable/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { inject } from './inject'; +export { extract } from './extract'; diff --git a/src/plugins/links/common/embeddable/inject.test.ts b/src/plugins/links/common/embeddable/inject.test.ts new file mode 100644 index 0000000000000..4fdef93f8e3a9 --- /dev/null +++ b/src/plugins/links/common/embeddable/inject.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { 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 new file mode 100644 index 0000000000000..134a508406361 --- /dev/null +++ b/src/plugins/links/common/embeddable/inject.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; +import { 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 new file mode 100644 index 0000000000000..b916d34f70840 --- /dev/null +++ b/src/plugins/links/common/embeddable/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; +import { SerializableRecord } from '@kbn/utility-types'; + +export type LinksPersistableState = EmbeddableStateWithType & { + attributes: SerializableRecord; +}; diff --git a/src/plugins/links/common/index.ts b/src/plugins/links/common/index.ts new file mode 100644 index 0000000000000..9cb4fc42124aa --- /dev/null +++ b/src/plugins/links/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/links/common/mocks.tsx b/src/plugins/links/common/mocks.tsx new file mode 100644 index 0000000000000..299f9edcacdc4 --- /dev/null +++ b/src/plugins/links/common/mocks.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { buildMockDashboard } from '@kbn/dashboard-plugin/public/mocks'; +import { DashboardContainerInput } from '@kbn/dashboard-plugin/common'; +import { LinksByValueInput } from '../public/embeddable/types'; +import { LinksFactoryDefinition } from '../public'; +import { LinksAttributes } from './content_management'; + +jest.mock('../public/services/attribute_service', () => { + return { + getLinksAttributeService: jest.fn(() => { + return { + saveMethod: jest.fn(), + unwrapMethod: jest.fn(), + checkForDuplicateTitle: jest.fn(), + unwrapAttributes: jest.fn((attributes: LinksByValueInput) => Promise.resolve(attributes)), + wrapAttributes: jest.fn((attributes: LinksAttributes) => Promise.resolve(attributes)), + }; + }), + }; +}); + +export const mockLinksInput = (partial?: Partial): LinksByValueInput => ({ + id: 'mocked_links_panel', + attributes: { + title: 'mocked_links', + }, + ...(partial ?? {}), +}); + +export const mockLinksPanel = async ({ + explicitInput, + dashboardExplicitInput, +}: { + explicitInput?: Partial; + dashboardExplicitInput?: Partial; +}) => { + const dashboardContainer = buildMockDashboard({ + overrides: dashboardExplicitInput, + savedObjectId: '123', + }); + const linksFactoryStub = new LinksFactoryDefinition(); + + const links = await linksFactoryStub.create(mockLinksInput(explicitInput), dashboardContainer); + + return links; +}; diff --git a/src/plugins/links/common/persistable_state/index.ts b/src/plugins/links/common/persistable_state/index.ts new file mode 100644 index 0000000000000..c3e09839f0f2f --- /dev/null +++ b/src/plugins/links/common/persistable_state/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { extractReferences, injectReferences } from './references'; diff --git a/src/plugins/links/common/persistable_state/references.test.ts b/src/plugins/links/common/persistable_state/references.test.ts new file mode 100644 index 0000000000000..cf74ba929b1aa --- /dev/null +++ b/src/plugins/links/common/persistable_state/references.test.ts @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE } from '../content_management'; +import { extractReferences, injectReferences } from './references'; + +describe('extractReferences', () => { + test('should handle missing links attribute', () => { + const attributes = { + title: 'my links', + }; + expect(extractReferences({ attributes })).toEqual({ + attributes: { + title: 'my links', + }, + references: [], + }); + }); + + test('should extract dashboard references from dashboard links', () => { + const attributes = { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destination: '19e149f0-e95e-404b-b6f8-fc751317c6be', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: EXTERNAL_LINK_TYPE as typeof EXTERNAL_LINK_TYPE, + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destination: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + order: 2, + }, + ], + }; + expect(extractReferences({ attributes })).toEqual({ + attributes: { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: 'dashboardLink', + destinationRefName: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: 'externalLink', + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: 'dashboardLink', + destinationRefName: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + order: 2, + }, + ], + }, + references: [ + { + id: '19e149f0-e95e-404b-b6f8-fc751317c6be', + name: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + type: 'dashboard', + }, + { + id: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + name: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + type: 'dashboard', + }, + ], + }); + }); +}); + +describe('injectReferences', () => { + test('should handle missing links attribute', () => { + const attributes = { + title: 'my links', + }; + expect(injectReferences({ attributes, references: [] })).toEqual({ + attributes: { + title: 'my links', + }, + }); + }); + + test('should inject dashboard references into dashboard links', () => { + const attributes = { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destinationRefName: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: EXTERNAL_LINK_TYPE as typeof EXTERNAL_LINK_TYPE, + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: DASHBOARD_LINK_TYPE as typeof DASHBOARD_LINK_TYPE, + destinationRefName: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + order: 2, + }, + ], + }; + const references = [ + { + id: '19e149f0-e95e-404b-b6f8-fc751317c6be', + name: 'link_fb1b3fc7-6e12-4542-bcf5-c61ad77241c5_dashboard', + type: 'dashboard', + }, + { + id: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + name: 'link_1409fabb-1d2b-49c2-a2dc-705bd8fabd0c_dashboard', + type: 'dashboard', + }, + ]; + expect(injectReferences({ attributes, references })).toEqual({ + attributes: { + title: 'my links', + links: [ + { + id: 'fb1b3fc7-6e12-4542-bcf5-c61ad77241c5', + type: 'dashboardLink', + destination: '19e149f0-e95e-404b-b6f8-fc751317c6be', + order: 0, + }, + { + id: '4d5cd000-5632-4d3a-ad41-11d7800ff2aa', + type: 'externalLink', + destination: 'https://example.com', + order: 1, + }, + { + id: '1409fabb-1d2b-49c2-a2dc-705bd8fabd0c', + type: 'dashboardLink', + destination: '39555f99-a3b8-4210-b1ef-fa0fa86fa3da', + order: 2, + }, + ], + }, + }); + }); +}); diff --git a/src/plugins/links/common/persistable_state/references.ts b/src/plugins/links/common/persistable_state/references.ts new file mode 100644 index 0000000000000..1410cdc53d234 --- /dev/null +++ b/src/plugins/links/common/persistable_state/references.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Reference } from '@kbn/content-management-utils'; +import { DASHBOARD_LINK_TYPE, LinksAttributes } from '../content_management'; + +export function extractReferences({ + attributes, + references = [], +}: { + attributes: LinksAttributes; + references?: Reference[]; +}) { + if (!attributes.links) { + return { attributes, references }; + } + + const { links } = attributes; + const extractedReferences: Reference[] = []; + links.forEach((link) => { + if (link.type === DASHBOARD_LINK_TYPE && link.destination) { + const refName = `link_${link.id}_dashboard`; + link.destinationRefName = refName; + extractedReferences.push({ + name: refName, + type: 'dashboard', + id: link.destination, + }); + delete link.destination; + } + }); + + return { + attributes: { + ...attributes, + links, + }, + references: references.concat(extractedReferences), + }; +} + +function findReference(targetName: string, references: Reference[]) { + const reference = references.find(({ name }) => name === targetName); + if (!reference) { + throw new Error(`Could not find reference "${targetName}"`); + } + return reference; +} + +export function injectReferences({ + attributes, + references, +}: { + attributes: LinksAttributes; + references: Reference[]; +}) { + if (!attributes.links) { + return { attributes }; + } + + const { links } = attributes; + links.forEach((link) => { + if (link.type === DASHBOARD_LINK_TYPE && link.destinationRefName) { + const reference = findReference(link.destinationRefName, references); + link.destination = reference.id; + delete link.destinationRefName; + } + }); + + return { + attributes: { + ...attributes, + links, + }, + }; +} diff --git a/src/plugins/links/common/types.ts b/src/plugins/links/common/types.ts new file mode 100644 index 0000000000000..54c14afec77bf --- /dev/null +++ b/src/plugins/links/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'; +import { CONTENT_ID } from './constants'; + +export type LinksContentType = typeof CONTENT_ID; + +export interface SharingSavedObjectProps { + outcome: SavedObjectsResolveResponse['outcome']; + aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; + sourceId?: string; +} diff --git a/src/plugins/links/jest.config.js b/src/plugins/links/jest.config.js new file mode 100644 index 0000000000000..51cc1202f61aa --- /dev/null +++ b/src/plugins/links/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/links'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/links', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/src/plugins/links/{common,public,server}/**/*.{ts,tsx}'], + setupFiles: ['/src/plugins/links/jest_setup.ts'], +}; diff --git a/src/plugins/links/jest_setup.ts b/src/plugins/links/jest_setup.ts new file mode 100644 index 0000000000000..32ed14af1efcc --- /dev/null +++ b/src/plugins/links/jest_setup.ts @@ -0,0 +1,13 @@ +/* + * 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 { setStubDashboardServices } from '@kbn/dashboard-plugin/public/services/mocks'; +import { setStubKibanaServices } from './public/mocks'; + +setStubKibanaServices(); +setStubDashboardServices(); diff --git a/src/plugins/links/kibana.jsonc b/src/plugins/links/kibana.jsonc new file mode 100644 index 0000000000000..5f0796d55b43a --- /dev/null +++ b/src/plugins/links/kibana.jsonc @@ -0,0 +1,22 @@ +{ + "type": "plugin", + "id": "@kbn/links-plugin", + "owner": "@elastic/kibana-presentation", + "description": "A dashboard panel for creating links to dashboards or external links.", + "plugin": { + "id": "links", + "server": true, + "browser": true, + "requiredPlugins": [ + "contentManagement", + "dashboard", + "embeddable", + "kibanaReact", + "presentationUtil", + "uiActionsEnhanced", + "kibanaUtils" + ], + "optionalPlugins": ["triggersActionsUi"], + "requiredBundles": ["savedObjects"] + } +} diff --git a/src/plugins/links/public/_mixins.scss b/src/plugins/links/public/_mixins.scss new file mode 100644 index 0000000000000..cc9b7a5168d80 --- /dev/null +++ b/src/plugins/links/public/_mixins.scss @@ -0,0 +1,38 @@ +@import '../../../core/public/mixins'; + +@keyframes euiFlyoutOpenAnimation { + 0% { + opacity: 0; + transform: translateX(100%); + } + + 100% { + opacity: 1; + transform: translateX(0%); + } +} + +@keyframes euiFlyoutCloseAnimation { + 0% { + opacity: 1; + transform: translateX(0%); + } + + 100% { + opacity: 0; + transform: translateX(100%); + } +} + +@mixin euiFlyout { + @include kibanaFullBodyHeight(); + position: fixed; + display: flex; + inline-size: 50vw; + z-index: $euiZFlyout; + align-items: stretch; + flex-direction: column; + border-left: $euiBorderThin; + background: $euiColorEmptyShade; + min-width: ($euiSizeXL * 13) + $euiSizeS; // 424px +} \ No newline at end of file diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx new file mode 100644 index 0000000000000..90dbdd434e2eb --- /dev/null +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.test.tsx @@ -0,0 +1,224 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import userEvent from '@testing-library/user-event'; +import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS } from '@kbn/presentation-util-plugin/public'; +import { DashboardLinkStrings } from './dashboard_link_strings'; +import { LinksEmbeddable, LinksContext } from '../../embeddable/links_embeddable'; +import { mockLinksPanel } from '../../../common/mocks'; +import { LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; +import { DashboardLinkComponent } from './dashboard_link_component'; +import { fetchDashboard, getDashboardHref, getDashboardLocator } from './dashboard_link_tools'; +import { coreServices } from '../../services/kibana_services'; + +jest.mock('./dashboard_link_tools'); + +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, + id: 'foo', + type: 'dashboardLink' as const, + }; + + let linksEmbeddable: LinksEmbeddable; + beforeEach(async () => { + window.open = jest.fn(); + (fetchDashboard as jest.Mock).mockResolvedValue(mockDashboards[0]); + (getDashboardLocator as jest.Mock).mockResolvedValue({ + app: 'dashboard', + path: '/dashboardItem/456', + state: {}, + }); + (getDashboardHref as jest.Mock).mockReturnValue('https://my-kibana.com/dashboard/123'); + linksEmbeddable = await mockLinksPanel({ + dashboardExplicitInput: mockDashboards[1].attributes, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('by default uses navigateToApp to open in same tab', async () => { + render( + + + + ); + + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + expect(fetchDashboard).toHaveBeenCalledWith(defaultLinkInfo.destination); + expect(getDashboardLocator).toHaveBeenCalledTimes(1); + expect(getDashboardLocator).toHaveBeenCalledWith({ + link: { + ...defaultLinkInfo, + options: DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, + }, + linksEmbeddable, + }); + + const link = await screen.findByTestId('dashboardLink--foo'); + expect(link).toHaveTextContent('another dashboard'); + await userEvent.click(link); + expect(coreServices.application.navigateToApp).toBeCalledTimes(1); + expect(coreServices.application.navigateToApp).toBeCalledWith('dashboard', { + path: '/dashboardItem/456', + state: {}, + }); + }); + + test('modified click does not trigger event.preventDefault', async () => { + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + const link = await screen.findByTestId('dashboardLink--foo'); + const clickEvent = createEvent.click(link, { ctrlKey: true }); + const preventDefault = jest.spyOn(clickEvent, 'preventDefault'); + fireEvent(link, clickEvent); + expect(preventDefault).toHaveBeenCalledTimes(0); + }); + + test('openInNewTab uses window.open, not navigateToApp', async () => { + const linkInfo = { + ...defaultLinkInfo, + options: { ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, openInNewTab: true }, + }; + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + expect(fetchDashboard).toHaveBeenCalledWith(linkInfo.destination); + expect(getDashboardLocator).toHaveBeenCalledWith({ link: linkInfo, linksEmbeddable }); + const link = await screen.findByTestId('dashboardLink--foo'); + expect(link).toBeInTheDocument(); + await userEvent.click(link); + expect(coreServices.application.navigateToApp).toBeCalledTimes(0); + expect(window.open).toHaveBeenCalledWith('https://my-kibana.com/dashboard/123', '_blank'); + }); + + test('passes linkOptions to getDashboardLocator', async () => { + const linkInfo = { + ...defaultLinkInfo, + options: { + ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, + useCurrentFilters: false, + useCurrentTimeRange: false, + useCurrentDateRange: false, + }, + }; + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + expect(getDashboardLocator).toHaveBeenCalledWith({ link: linkInfo, linksEmbeddable }); + }); + + test('shows an error when fetchDashboard fails', async () => { + (fetchDashboard as jest.Mock).mockRejectedValue(new Error('some error')); + const linkInfo = { + ...defaultLinkInfo, + id: 'notfound', + }; + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + const link = await screen.findByTestId('dashboardLink--notfound--error'); + expect(link).toHaveTextContent(DashboardLinkStrings.getDashboardErrorLabel()); + }); + + test('current dashboard is not a clickable href', async () => { + const linkInfo = { + ...defaultLinkInfo, + destination: '123', + id: 'bar', + }; + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + const link = await screen.findByTestId('dashboardLink--bar'); + expect(link).toHaveTextContent('current dashboard'); + await userEvent.click(link); + expect(coreServices.application.navigateToApp).toBeCalledTimes(0); + expect(window.open).toBeCalledTimes(0); + }); + + test('shows dashboard title and description in tooltip', async () => { + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + const link = await screen.findByTestId('dashboardLink--foo'); + await userEvent.hover(link); + const tooltip = await screen.findByTestId('dashboardLink--foo--tooltip'); + expect(tooltip).toHaveTextContent('another dashboard'); // title + expect(tooltip).toHaveTextContent('something awesome'); // description + }); + + test('can override link label', async () => { + const label = 'my custom label'; + const linkInfo = { + ...defaultLinkInfo, + label, + }; + render( + + + + ); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(1)); + const link = await screen.findByTestId('dashboardLink--foo'); + expect(link).toHaveTextContent(label); + await userEvent.hover(link); + const tooltip = await screen.findByTestId('dashboardLink--foo--tooltip'); + expect(tooltip).toHaveTextContent(label); + }); +}); 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 new file mode 100644 index 0000000000000..563cf6277c796 --- /dev/null +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx @@ -0,0 +1,171 @@ +/* + * 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 classNames from 'classnames'; +import useAsync from 'react-use/lib/useAsync'; +import React, { useMemo, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; + +import { + DashboardDrilldownOptions, + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, +} from '@kbn/presentation-util-plugin/public'; +import { EuiButtonEmpty, EuiListGroupItem } from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { LINKS_VERTICAL_LAYOUT, LinksLayoutType, Link } from '../../../common/content_management'; +import { coreServices } from '../../services/kibana_services'; +import { DashboardLinkStrings } from './dashboard_link_strings'; +import { useLinks } from '../../embeddable/links_embeddable'; +import { fetchDashboard, getDashboardHref, getDashboardLocator } from './dashboard_link_tools'; + +export const DashboardLinkComponent = ({ + link, + layout, +}: { + link: Link; + layout: LinksLayoutType; +}) => { + 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]); + + /** + * Returns the title and description of the dashboard that the link points to; note that, if the link points to + * the current dashboard, then we need to get the most up-to-date information via the `parentDashboardInput` - this + * will respond to changes so that the link label/tooltip remains in sync with the dashboard title/description. + */ + const [dashboardTitle, dashboardDescription] = useMemo(() => { + return link.destination === parentDashboardId + ? [parentDashboardInput?.title, parentDashboardInput?.description] + : [destinationDashboard?.attributes.title, destinationDashboard?.attributes.description]; + }, [link.destination, parentDashboardId, parentDashboardInput, destinationDashboard]); + + /** + * Memoized link information + */ + const linkLabel = useMemo(() => { + return link.label || (dashboardTitle ?? DashboardLinkStrings.getDashboardErrorLabel()); + }, [link, dashboardTitle]); + + const { tooltipTitle, tooltipMessage } = useMemo(() => { + if (error) { + return { + tooltipTitle: DashboardLinkStrings.getDashboardErrorLabel(), + tooltipMessage: error.message, + }; + } + return { + tooltipTitle: Boolean(dashboardDescription) ? linkLabel : undefined, + tooltipMessage: dashboardDescription || linkLabel, + }; + }, [error, linkLabel, dashboardDescription]); + + /** + * Dashboard-to-dashboard navigation + */ + const { loading: loadingOnClickProps, value: onClickProps } = useAsync(async () => { + /** If the link points to the current dashboard, then there should be no `onClick` or `href` prop */ + if (link.destination === parentDashboardId) return; + + const linkOptions = { + ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, + ...link.options, + } as DashboardDrilldownOptions; + + const locator = await getDashboardLocator({ + link: { ...link, options: linkOptions }, + linksEmbeddable, + }); + if (!locator) return; + + const href = getDashboardHref(locator); + return { + href, + onClick: async (event: React.MouseEvent) => { + /** + * If the link is being opened via a modified click, then we should use the default `href` navigation behaviour + * by passing all the dashboard state via the URL - this will keep behaviour consistent across all browsers. + */ + const modifiedClick = event.ctrlKey || event.metaKey || event.shiftKey; + if (modifiedClick) { + return; + } + + /** Otherwise, prevent the default behaviour and handle click depending on `openInNewTab` option */ + event.preventDefault(); + if (linkOptions.openInNewTab) { + window.open(href, '_blank'); + } else { + const { app, path, state } = locator; + await coreServices.application.navigateToApp(app, { + path, + state, + }); + } + }, + }; + }, [link]); + + const id = `dashboardLink--${link.id}`; + + return loadingDestinationDashboard ? ( +
  • + + {DashboardLinkStrings.getLoadingDashboardLabel()} + +
  • + ) : ( + + ); +}; 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 new file mode 100644 index 0000000000000..137d604c2e01e --- /dev/null +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_destination_picker.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { debounce } from 'lodash'; +import useAsync from 'react-use/lib/useAsync'; +import useMount from 'react-use/lib/useMount'; +import useUnmount from 'react-use/lib/useUnmount'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { + EuiBadge, + EuiComboBox, + EuiFlexItem, + EuiHighlight, + EuiFlexGroup, + EuiComboBoxOptionOption, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { DashboardItem } from '../../embeddable/types'; +import { DashboardLinkStrings } from './dashboard_link_strings'; +import { fetchDashboard, fetchDashboards } from './dashboard_link_tools'; + +type DashboardComboBoxOption = EuiComboBoxOptionOption; + +export const DashboardLinkDestinationPicker = ({ + onDestinationPicked, + initialSelection, + parentDashboard, + onUnmount, + ...other +}: { + initialSelection?: string; + parentDashboard?: DashboardContainer; + 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, + value: dashboard, + label: dashboard.attributes.title, + className: 'linksDashboardItem', + }; + }, []); + + useMount(async () => { + if (initialSelection) { + const dashboard = await fetchDashboard(initialSelection).catch(() => { + /** + * Swallow the error that is thrown, since this just means the selected dashboard was deleted and + * so we should treat this the same as "no previous selection." + */ + }); + if (dashboard) { + onDestinationPicked(dashboard); + setSelectedOption([getDashboardItem(dashboard)]); + } else { + onDestinationPicked(undefined); + } + } + }); + + useUnmount(() => { + /** Save the current selection so we can re-populate it if we switch back to this link editor */ + onUnmount(selectedOption[0]?.key); + }); + + const { loading: loadingDashboardList, value: dashboardList } = useAsync(async () => { + const dashboards = await fetchDashboards({ + search: searchString, + parentDashboardId, + selectedDashboardId: initialSelection, + }); + const dashboardOptions = (dashboards ?? []).map((dashboard: DashboardItem) => { + return getDashboardItem(dashboard); + }); + return dashboardOptions; + }, [searchString, parentDashboardId, getDashboardItem]); + + const debouncedSetSearch = useMemo( + () => + debounce((newSearch: string) => { + setSearchString(newSearch); + }, 250), + [setSearchString] + ); + + const renderOption = useCallback( + (option, searchValue, contentClassName) => { + const { label, key: dashboardId } = option; + return ( + + {dashboardId === parentDashboardId && ( + + {DashboardLinkStrings.getCurrentDashboardLabel()} + + )} + + + {label} + + + + ); + }, + [parentDashboardId] + ); + + /* {...other} is needed so the EuiComboBox is treated as part of the form */ + return ( + { + debouncedSetSearch(searchValue); + }} + renderOption={renderOption} + selectedOptions={selectedOption} + onChange={(option) => { + setSelectedOption(option); + if (option.length > 0) { + // single select is `true`, so there is only ever one item in the array + onDestinationPicked(option[0].value); + } else { + onDestinationPicked(undefined); + } + }} + data-test-subj="links--linkEditor--dashboardLink--comboBox" + /> + ); +}; diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_strings.ts b/src/plugins/links/public/components/dashboard_link/dashboard_link_strings.ts new file mode 100644 index 0000000000000..873a7ca51c7fe --- /dev/null +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_strings.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const DashboardLinkStrings = { + getType: () => + i18n.translate('links.dashboardLink.type', { + defaultMessage: 'Dashboard link', + }), + getDisplayName: () => + i18n.translate('links.dashboardLink.displayName', { + defaultMessage: 'Dashboard', + }), + getDescription: () => + i18n.translate('links.dashboardLink.description', { + defaultMessage: 'Go to dashboard', + }), + getDashboardPickerPlaceholder: () => + i18n.translate('links.dashboardLink.editor.dashboardComboBoxPlaceholder', { + defaultMessage: 'Search for a dashboard', + }), + getDashboardPickerAriaLabel: () => + i18n.translate('links.dashboardLink.editor.dashboardPickerAriaLabel', { + defaultMessage: 'Pick a destination dashboard', + }), + getCurrentDashboardLabel: () => + i18n.translate('links.dashboardLink.editor.currentDashboardLabel', { + defaultMessage: 'Current', + }), + getLoadingDashboardLabel: () => + i18n.translate('links.dashboardLink.editor.loadingDashboardLabel', { + defaultMessage: 'Loading...', + }), + getDashboardErrorLabel: () => + i18n.translate('links.dashboardLink.editor.dashboardErrorLabel', { + defaultMessage: 'Error fetching dashboard', + }), +}; 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 new file mode 100644 index 0000000000000..eb51758bd9b68 --- /dev/null +++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_tools.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isEmpty, filter } from 'lodash'; + +import { + cleanEmptyKeys, + getEmbeddableParams, + DashboardAppLocatorParams, +} from '@kbn/dashboard-plugin/public'; +import { isFilterPinned } from '@kbn/es-query'; +import { KibanaLocation } from '@kbn/share-plugin/public'; +import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; +import { DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; + +import { DashboardItem } from '../../embeddable/types'; +import type { LinksEmbeddable } from '../../embeddable'; +import { Link } from '../../../common/content_management'; +import { coreServices, dashboardServices } from '../../services/kibana_services'; + +/** + * ---------------------------------- + * Fetch a single dashboard + * ---------------------------------- + */ + +export const fetchDashboard = async (dashboardId: string): Promise => { + const findDashboardsService = await dashboardServices.findDashboardsService(); + const response = await findDashboardsService.findById(dashboardId); + if (response.status === 'error') { + throw new Error(response.error.message); + } + return response; +}; + +/** + * ---------------------------------- + * Fetch lists of dashboards + * ---------------------------------- + */ + +interface FetchDashboardsProps { + size?: number; + search?: string; + parentDashboardId?: string; + selectedDashboardId?: string; +} + +export const fetchDashboards = async ({ + search = '', + size = 10, + parentDashboardId, + selectedDashboardId, +}: FetchDashboardsProps): Promise => { + const findDashboardsService = await dashboardServices.findDashboardsService(); + const responses = await findDashboardsService.search({ + search, + size, + options: { onlyTitle: true }, + }); + + let dashboardList: DashboardItem[] = responses.hits; + + /** If there is no search string... */ + if (isEmpty(search)) { + /** ... filter out both the parent and selected dashboard from the list ... */ + dashboardList = filter(dashboardList, (dash) => { + return dash.id !== parentDashboardId && dash.id !== selectedDashboardId; + }); + + /** ... so that we can force them to the top of the list as necessary. */ + if (parentDashboardId) { + dashboardList.unshift(await fetchDashboard(parentDashboardId)); + } + + if (selectedDashboardId && selectedDashboardId !== parentDashboardId) { + const selectedDashboard = await fetchDashboard(selectedDashboardId).catch(() => { + /** + * Swallow the error thrown, since this just means the selected dashboard was deleted and therefore + * it should not be added to the top of the dashboard list + */ + }); + if (selectedDashboard) dashboardList.unshift(await fetchDashboard(selectedDashboardId)); + } + } + + /** Then, only return the parts of the dashboard object that we need */ + const simplifiedDashboardList = dashboardList.map((hit) => { + return { id: hit.id, attributes: hit.attributes }; + }); + + return simplifiedDashboardList; +}; + +/** + * ---------------------------------- + * Navigate from one dashboard to another + * ---------------------------------- + */ + +interface GetDashboardLocatorProps { + link: Link & { options: DashboardDrilldownOptions }; + linksEmbeddable: LinksEmbeddable; +} + +/** + * Fetch the locator to use for dashboard navigation + * @param props `GetDashboardLocatorProps` + * @returns The locator to use for dashboard navigation + */ +export const getDashboardLocator = async ({ link, linksEmbeddable }: GetDashboardLocatorProps) => { + const params: DashboardAppLocatorParams = { + dashboardId: link.destination, + ...getEmbeddableParams(linksEmbeddable, link.options), + }; + + const locator = dashboardServices.locator; // TODO: Make this generic as part of https://github.com/elastic/kibana/issues/164748 + if (locator) { + const location: KibanaLocation = await locator.getLocation(params); + return location; + } +}; + +/** + * Get URL for dashboard app - should only be used when relying on native `href` functionality + * @param locator Locator that should be used to get the URL + * @returns A full URL to the dashboard, with all state included + */ +export const getDashboardHref = ({ + app, + path, + state, +}: KibanaLocation): string => { + return coreServices.application.getUrlForApp(app, { + path: setStateToKbnUrl( + '_a', + cleanEmptyKeys({ + query: state.query, + filters: state.filters?.filter((f) => !isFilterPinned(f)), + }), + { useHash: false, storeInHashQuery: true }, + path + ), + absolute: true, + }); +}; diff --git a/src/plugins/links/public/components/editor/link_destination.tsx b/src/plugins/links/public/components/editor/link_destination.tsx new file mode 100644 index 0000000000000..bd33b6245ab51 --- /dev/null +++ b/src/plugins/links/public/components/editor/link_destination.tsx @@ -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 React, { useState } from 'react'; + +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { EuiFormRow } from '@elastic/eui'; +import { + LinkType, + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, +} from '../../../common/content_management'; +import { UnorderedLink } from '../../editor/open_link_editor_flyout'; +import { ExternalLinkDestinationPicker } from '../external_link/external_link_destination_picker'; +import { DashboardLinkDestinationPicker } from '../dashboard_link/dashboard_link_destination_picker'; +import { LinksStrings } from '../links_strings'; + +export const LinkDestination = ({ + link, + setDestination, + parentDashboard, + selectedLinkType, +}: { + selectedLinkType: LinkType; + parentDashboard?: DashboardContainer; + link?: UnorderedLink; + setDestination: (destination?: string, defaultLabel?: string) => void; +}) => { + const [destinationError, setDestinationError] = useState(); + + /** + * Store the dashboard / external destinations separately so that we can remember the selections + * made in each component even when the selected link type changes + */ + const [dashboardLinkDestination, setDashboardLinkDestination] = useState( + link && link.type === DASHBOARD_LINK_TYPE ? link.destination : undefined + ); + const [externalLinkDestination, setExternalLinkDestination] = useState( + link && link.type === EXTERNAL_LINK_TYPE ? link.destination : undefined + ); + + const isInvalid = Boolean(destinationError); + + return ( + + {selectedLinkType === DASHBOARD_LINK_TYPE ? ( + { + setDestination(undefined, undefined); + if (selectedDashboardId) setDashboardLinkDestination(selectedDashboardId); + }} + parentDashboard={parentDashboard} + initialSelection={dashboardLinkDestination} + onDestinationPicked={(dashboard) => + setDestination(dashboard?.id, dashboard?.attributes.title) + } + /> + ) : ( + { + setDestinationError(undefined); + setDestination(undefined, undefined); + if (selectedUrl) setExternalLinkDestination(selectedUrl); + }} + initialSelection={externalLinkDestination} + onDestinationPicked={(url) => setDestination(url, url)} + setDestinationError={setDestinationError} + /> + )} + + ); +}; diff --git a/src/plugins/links/public/components/editor/link_editor.tsx b/src/plugins/links/public/components/editor/link_editor.tsx new file mode 100644 index 0000000000000..5cd4c60870c5e --- /dev/null +++ b/src/plugins/links/public/components/editor/link_editor.tsx @@ -0,0 +1,188 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { + EuiForm, + EuiIcon, + EuiTitle, + EuiButton, + EuiFormRow, + EuiFlexItem, + EuiFieldText, + EuiFocusTrap, + EuiFlexGroup, + EuiRadioGroup, + EuiFlyoutBody, + EuiButtonEmpty, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiRadioGroupOption, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { + LinkType, + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + LinkOptions, + Link, +} from '../../../common/content_management'; +import { LinksStrings } from '../links_strings'; +import { LinkInfo } from '../../embeddable/types'; +import { LinkOptionsComponent } from './link_options'; +import { UnorderedLink } from '../../editor/open_link_editor_flyout'; +import { LinkDestination } from './link_destination'; + +export const LinkEditor = ({ + link, + onSave, + onClose, + parentDashboard, +}: { + onClose: () => void; + parentDashboard?: DashboardContainer; + link?: UnorderedLink; // will only be defined if **editing** a link; otherwise, creating a new link + onSave: (newLink: Omit) => void; +}) => { + const [selectedLinkType, setSelectedLinkType] = useState( + link?.type ?? DASHBOARD_LINK_TYPE + ); + const [defaultLinkLabel, setDefaultLinkLabel] = useState(); + const [currentLinkLabel, setCurrentLinkLabel] = useState(link?.label ?? ''); + const [linkOptions, setLinkOptions] = useState(); + const [linkDestination, setLinkDestination] = useState(link?.destination); + + const linkTypes: EuiRadioGroupOption[] = useMemo(() => { + return ([DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE] as LinkType[]).map((type) => { + return { + id: type, + label: ( + + + + + {LinkInfo[type].displayName} + + ), + 'data-test-subj': `links--linkEditor--${type}--radioBtn`, + }; + }); + }, []); + + /** 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) => { + setLinkDestination(destination); + if (!currentLinkLabel || defaultLinkLabel === currentLinkLabel) { + setCurrentLinkLabel(label ?? ''); + } + setDefaultLinkLabel(label); + }, + [defaultLinkLabel, currentLinkLabel] + ); + + return ( + + + onClose()} + > + +

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

    +
    +
    +
    + + + + { + if (currentLinkLabel === defaultLinkLabel) { + setCurrentLinkLabel(link?.type === id ? link.label ?? '' : ''); + } + setSelectedLinkType(id as LinkType); + }} + /> + + + + setCurrentLinkLabel(e.target.value)} + data-test-subj="links--linkEditor--linkLabel--input" + /> + + + + + + + + onClose()} + iconType="cross" + data-test-subj="links--linkEditor--closeBtn" + > + {LinksStrings.editor.getCancelButtonLabel()} + + + + { + // this check should always be true, since the button is disabled otherwise - this is just for type safety + if (linkDestination) { + onSave({ + label: currentLinkLabel === defaultLinkLabel ? undefined : currentLinkLabel, + type: selectedLinkType, + id: link?.id ?? uuidv4(), + destination: linkDestination, + options: linkOptions, + }); + + onClose(); + } + }} + data-test-subj="links--linkEditor--saveBtn" + > + {link + ? LinksStrings.editor.getUpdateButtonLabel() + : LinksStrings.editor.getAddButtonLabel()} + + + + +
    + ); +}; diff --git a/src/plugins/links/public/components/editor/link_options.tsx b/src/plugins/links/public/components/editor/link_options.tsx new file mode 100644 index 0000000000000..46d1f5d1086de --- /dev/null +++ b/src/plugins/links/public/components/editor/link_options.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; + +import { EuiFormRow } from '@elastic/eui'; +import { + DashboardDrilldownOptions, + DashboardDrilldownOptionsComponent, + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, +} from '@kbn/presentation-util-plugin/public'; +import { + UrlDrilldownOptions, + UrlDrilldownOptionsComponent, + DEFAULT_URL_DRILLDOWN_OPTIONS, +} from '@kbn/ui-actions-enhanced-plugin/public'; + +import { + LinkType, + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + LinkOptions, +} from '../../../common/content_management'; +import { LinksStrings } from '../links_strings'; +import { UnorderedLink } from '../../editor/open_link_editor_flyout'; + +export const LinkOptionsComponent = ({ + link, + setLinkOptions, + selectedLinkType, +}: { + selectedLinkType: LinkType; + link?: UnorderedLink; + setLinkOptions: (options: LinkOptions) => void; +}) => { + const [dashboardLinkOptions, setDashboardLinkOptions] = useState({ + ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, + ...(link && link.type === DASHBOARD_LINK_TYPE ? link.options : {}), + }); + const [externalLinkOptions, setExternalLinkOptions] = useState({ + ...DEFAULT_URL_DRILLDOWN_OPTIONS, + ...(link && link.type === EXTERNAL_LINK_TYPE ? link.options : {}), + }); + + return ( + + {selectedLinkType === DASHBOARD_LINK_TYPE ? ( + { + setDashboardLinkOptions({ ...dashboardLinkOptions, ...change }); + setLinkOptions({ ...dashboardLinkOptions, ...change }); + }} + /> + ) : ( + { + setExternalLinkOptions({ ...externalLinkOptions, ...change }); + setLinkOptions({ ...externalLinkOptions, ...change }); + }} + /> + )} + + ); +}; diff --git a/src/plugins/links/public/components/editor/links_editor.scss b/src/plugins/links/public/components/editor/links_editor.scss new file mode 100644 index 0000000000000..3eb0d574ddf27 --- /dev/null +++ b/src/plugins/links/public/components/editor/links_editor.scss @@ -0,0 +1,77 @@ +@import '../../mixins'; + +.linksPanelEditor { + .linkEditor { + @include euiFlyout; + max-inline-size: $euiSizeXXL * 18; // 40px * 18 = 720px + + &.in { + animation: euiFlyoutOpenAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; + } + + &.out { + animation: euiFlyoutCloseAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; + } + + .linkEditorBackButton { + height: auto; + } + } +} + +.linksDashboardItem { + .euiBadge { + cursor: pointer !important; + } + + // in order to ensure that the "Current" badge doesn't recieve an underline on hover, we have to set the + // text-decoration to `none` for the entire list item and manually set the underline **only** on the text + &:hover { + text-decoration: none; + } + + .linksPanelLinkText { + &:hover { + text-decoration: underline !important; + } + } +} + +.linksPanelLink { + padding: $euiSizeXS $euiSizeS; + color: $euiTextColor; + + .linksPanelLinkText { + flex: 1; + min-width: 0; + } + + &.linkError { + border: 1px solid transparentize($euiColorWarningText, .7); + + .linksPanelLinkText { + color: $euiColorWarningText; + } + + .linksPanelLinkText--noLabel { + font-style: italic; + } + } + + .links_hoverActions { + opacity: 0; + visibility: hidden; + transition: visibility $euiAnimSpeedNormal, opacity $euiAnimSpeedNormal; + } + + &:hover, &:focus-within { + .links_hoverActions { + opacity: 1; + visibility: visible; + } + } +} + +.linksDroppableLinksArea { + margin: 0 (-$euiSizeXS); +} diff --git a/src/plugins/links/public/components/editor/links_editor.test.tsx b/src/plugins/links/public/components/editor/links_editor.test.tsx new file mode 100644 index 0000000000000..c9c1c0eff183d --- /dev/null +++ b/src/plugins/links/public/components/editor/links_editor.test.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import 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: [], + }) + ), + }; +}); + +describe('LinksEditor', () => { + const defaultProps = { + onSaveToLibrary: jest.fn().mockImplementation(() => Promise.resolve()), + onAddToDashboard: jest.fn(), + onClose: jest.fn(), + isByReference: false, + }; + + const someLinks: Link[] = [ + { + id: 'foo', + type: 'dashboardLink' as const, + order: 1, + destination: '123', + }, + { + id: 'bar', + type: 'dashboardLink' as const, + order: 4, + destination: '456', + }, + { + id: 'bizz', + type: 'externalLink' as const, + order: 3, + destination: 'http://example.com', + }, + { + id: 'buzz', + type: 'externalLink' as const, + order: 2, + destination: 'http://elastic.co', + }, + ]; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('shows empty state with no links', async () => { + render(); + expect(screen.getByTestId('links--panelEditor--title')).toHaveTextContent( + LinksStrings.editor.panelEditor.getCreateFlyoutTitle() + ); + expect(screen.getByTestId('links--panelEditor--emptyPrompt')).toBeInTheDocument(); + expect(screen.getByTestId('links--panelEditor--saveBtn')).toBeDisabled(); + + await userEvent.click(screen.getByTestId('links--panelEditor--closeBtn')); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + test('shows links in order', async () => { + const expectedLinkIds = [...someLinks].sort((a, b) => a.order - b.order).map(({ id }) => id); + render(); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId('links--panelEditor--title')).toHaveTextContent( + LinksStrings.editor.panelEditor.getEditFlyoutTitle() + ); + const draggableLinks = screen.getAllByTestId('links--panelEditor--draggableLink'); + expect(draggableLinks.length).toEqual(4); + + draggableLinks.forEach((link, idx) => { + expect(link).toHaveAttribute('data-rfd-draggable-id', expectedLinkIds[idx]); + }); + }); + + test('saving by reference panels calls onSaveToLibrary', async () => { + const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order); + render(); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); + const saveButton = screen.getByTestId('links--panelEditor--saveBtn'); + await userEvent.click(saveButton); + await waitFor(() => expect(defaultProps.onSaveToLibrary).toHaveBeenCalledTimes(1)); + expect(defaultProps.onSaveToLibrary).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT); + }); + + test('saving by value panel calls onAddToDashboard', async () => { + const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order); + render(); + await waitFor(() => expect(fetchDashboard).toHaveBeenCalledTimes(2)); + const saveButton = screen.getByTestId('links--panelEditor--saveBtn'); + await 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 new file mode 100644 index 0000000000000..0fb22efaf8507 --- /dev/null +++ b/src/plugins/links/public/components/editor/links_editor.tsx @@ -0,0 +1,318 @@ +/* + * 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, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + EuiForm, + EuiBadge, + EuiTitle, + EuiButton, + EuiSwitch, + EuiFormRow, + EuiToolTip, + EuiFlexItem, + EuiFlexGroup, + EuiDroppable, + EuiDraggable, + EuiFlyoutBody, + EuiButtonEmpty, + EuiButtonGroup, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiDragDropContext, + euiDragDropReorder, + EuiButtonGroupOptionProps, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { LinksLayoutInfo } from '../../embeddable/types'; +import { + Link, + LinksLayoutType, + LINKS_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, +} from '../../../common/content_management'; +import { coreServices } from '../../services/kibana_services'; +import { LinksStrings } from '../links_strings'; +import { openLinkEditorFlyout } from '../../editor/open_link_editor_flyout'; +import { memoizedGetOrderedLinkList } from '../../editor/links_editor_tools'; +import { LinksEditorEmptyPrompt } from './links_editor_empty_prompt'; +import { LinksEditorSingleLink } from './links_editor_single_link'; + +import { TooltipWrapper } from '../tooltip_wrapper'; + +import './links_editor.scss'; + +const layoutOptions: EuiButtonGroupOptionProps[] = [ + { + id: LINKS_VERTICAL_LAYOUT, + label: LinksLayoutInfo[LINKS_VERTICAL_LAYOUT].displayName, + 'data-test-subj': `links--panelEditor--${LINKS_VERTICAL_LAYOUT}LayoutBtn`, + }, + { + id: LINKS_HORIZONTAL_LAYOUT, + label: LinksLayoutInfo[LINKS_HORIZONTAL_LAYOUT].displayName, + 'data-test-subj': `links--panelEditor--${LINKS_HORIZONTAL_LAYOUT}LayoutBtn`, + }, +]; + +const LinksEditor = ({ + onSaveToLibrary, + onAddToDashboard, + onClose, + initialLinks, + initialLayout, + parentDashboard, + isByReference, +}: { + onSaveToLibrary: (newLinks: Link[], newLayout: LinksLayoutType) => Promise; + onAddToDashboard: (newLinks: Link[], newLayout: LinksLayoutType) => void; + onClose: () => void; + initialLinks?: Link[]; + initialLayout?: LinksLayoutType; + parentDashboard?: DashboardContainer; + isByReference: boolean; +}) => { + const toasts = coreServices.notifications.toasts; + const editLinkFlyoutRef = useRef(null); + + const [currentLayout, setCurrentLayout] = useState( + initialLayout ?? LINKS_VERTICAL_LAYOUT + ); + const [isSaving, setIsSaving] = useState(false); + const [orderedLinks, setOrderedLinks] = useState([]); + const [saveByReference, setSaveByReference] = useState(!initialLinks ? true : isByReference); + + const isEditingExisting = initialLinks || isByReference; + + useEffect(() => { + if (!initialLinks) { + setOrderedLinks([]); + return; + } + setOrderedLinks(memoizedGetOrderedLinkList(initialLinks)); + }, [initialLinks]); + + const onDragEnd = useCallback( + ({ source, destination }) => { + if (source && destination) { + const newList = euiDragDropReorder(orderedLinks, source.index, destination.index).map( + (link, i) => { + return { ...link, order: i }; + } + ); + setOrderedLinks(newList); + } + }, + [orderedLinks] + ); + + const addOrEditLink = useCallback( + async (linkToEdit?: Link) => { + const newLink = await openLinkEditorFlyout({ + parentDashboard, + link: linkToEdit, + ref: editLinkFlyoutRef, + }); + if (newLink) { + if (linkToEdit) { + setOrderedLinks( + orderedLinks.map((link) => { + if (link.id === linkToEdit.id) { + return { ...newLink, order: linkToEdit.order } as Link; + } + return link; + }) + ); + } else { + setOrderedLinks([...orderedLinks, { ...newLink, order: orderedLinks.length } as Link]); + } + } + }, + [editLinkFlyoutRef, orderedLinks, parentDashboard] + ); + + const hasZeroLinks = useMemo(() => { + return orderedLinks.length === 0; + }, [orderedLinks]); + + const deleteLink = useCallback( + (linkId: string) => { + setOrderedLinks( + orderedLinks.filter((link) => { + return link.id !== linkId; + }) + ); + }, + [orderedLinks] + ); + + return ( + <> +
    + + + + +

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

    +
    +
    + + + {/* The EuiBadge needs an empty title to prevent the default tooltip */} + + {LinksStrings.editor.panelEditor.getTechnicalPreviewLabel()} + + + +
    +
    + + + + { + setCurrentLayout(id as LinksLayoutType); + }} + legend={LinksStrings.editor.panelEditor.getLayoutSettingsLegend()} + /> + + + {/* Needs to be surrounded by a div rather than a fragment so the EuiFormRow can respond + to the focus of the inner elements */} +
    + {hasZeroLinks ? ( + addOrEditLink()} /> + ) : ( + <> + + + {orderedLinks.map((link, idx) => ( + + {(provided) => ( + addOrEditLink(link)} + deleteLink={() => deleteLink(link.id)} + dragHandleProps={provided.dragHandleProps ?? undefined} // casting `null` to `undefined` + /> + )} + + ))} + + + addOrEditLink()} + data-test-subj="links--panelEditor--addLinkBtn" + > + {LinksStrings.editor.getAddButtonLabel()} + + + )} +
    +
    +
    +
    + + + + + {LinksStrings.editor.getCancelButtonLabel()} + + + + + {!initialLinks || !isByReference ? ( + + + setSaveByReference(!saveByReference)} + data-test-subj="links--panelEditor--saveByReferenceSwitch" + /> + + + ) : null} + + + { + if (saveByReference) { + setIsSaving(true); + onSaveToLibrary(orderedLinks, currentLayout) + .catch((e) => { + toasts.addError(e, { + title: LinksStrings.editor.panelEditor.getErrorDuringSaveToastTitle(), + }); + }) + .finally(() => { + setIsSaving(false); + }); + } else { + onAddToDashboard(orderedLinks, currentLayout); + } + }} + > + {LinksStrings.editor.panelEditor.getSaveButtonLabel()} + + + + + + + + + ); +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default LinksEditor; diff --git a/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx b/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx new file mode 100644 index 0000000000000..763b08437e4d3 --- /dev/null +++ b/src/plugins/links/public/components/editor/links_editor_empty_prompt.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { EuiText, EuiPanel, EuiSpacer, EuiButton, EuiEmptyPrompt, EuiFormRow } from '@elastic/eui'; + +import { LinksStrings } from '../links_strings'; + +export const LinksEditorEmptyPrompt = ({ addLink }: { addLink: () => Promise }) => { + return ( + + + + {LinksStrings.editor.panelEditor.getEmptyLinksMessage()} + + + {LinksStrings.editor.getAddButtonLabel()} + + + } + /> + + + ); +}; 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 new file mode 100644 index 0000000000000..e6f7bc12ee6a4 --- /dev/null +++ b/src/plugins/links/public/components/editor/links_editor_single_link.tsx @@ -0,0 +1,185 @@ +/* + * 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 classNames from 'classnames'; +import React, { useMemo, useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { + EuiText, + EuiIcon, + EuiPanel, + EuiToolTip, + EuiFlexItem, + EuiFlexGroup, + EuiButtonIcon, + EuiSkeletonTitle, + DraggableProvidedDragHandleProps, +} from '@elastic/eui'; +import { 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 { LinksStrings } from '../links_strings'; +import { DashboardLinkStrings } from '../dashboard_link/dashboard_link_strings'; +import { DASHBOARD_LINK_TYPE, Link } from '../../../common/content_management'; + +export const LinksEditorSingleLink = ({ + link, + editLink, + deleteLink, + parentDashboard, + dragHandleProps, +}: { + editLink: () => void; + deleteLink: () => void; + link: Link; + parentDashboard?: DashboardContainer; + 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} + + + + + ); + + return () => + destinationError ? ( + + {labelText} + + ) : ( + labelText + ); + }, [linkLabel, linkLabelLoading, destinationError, link.label, link.type]); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/plugins/links/public/components/external_link/external_link_component.test.tsx b/src/plugins/links/public/components/external_link/external_link_component.test.tsx new file mode 100644 index 0000000000000..1afdc17c43563 --- /dev/null +++ b/src/plugins/links/public/components/external_link/external_link_component.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import userEvent from '@testing-library/user-event'; +import { createEvent, fireEvent, render, screen } from '@testing-library/react'; +import { LinksEmbeddable, LinksContext } from '../../embeddable/links_embeddable'; +import { mockLinksPanel } from '../../../common/mocks'; +import { LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; +import { ExternalLinkComponent } from './external_link_component'; +import { coreServices } from '../../services/kibana_services'; +import { DEFAULT_URL_DRILLDOWN_OPTIONS } from '@kbn/ui-actions-enhanced-plugin/public'; + +describe('external link component', () => { + const defaultLinkInfo = { + destination: 'https://example.com', + order: 1, + id: 'foo', + type: 'externalLink' as const, + }; + + let links: LinksEmbeddable; + beforeEach(async () => { + window.open = jest.fn(); + links = await mockLinksPanel({}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('by default opens in new tab', async () => { + render( + + + + ); + + const link = await screen.findByTestId('externalLink--foo'); + expect(link).toBeInTheDocument(); + await userEvent.click(link); + expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank'); + }); + + test('modified click does not trigger event.preventDefault', async () => { + const linkInfo = { + ...defaultLinkInfo, + options: { ...DEFAULT_URL_DRILLDOWN_OPTIONS, openInNewTab: false }, + }; + render( + + + + ); + const link = await screen.findByTestId('externalLink--foo'); + expect(link).toHaveTextContent('https://example.com'); + const clickEvent = createEvent.click(link, { ctrlKey: true }); + const preventDefault = jest.spyOn(clickEvent, 'preventDefault'); + fireEvent(link, clickEvent); + expect(preventDefault).toHaveBeenCalledTimes(0); + }); + + test('uses navigateToUrl when openInNewTab is false', async () => { + const linkInfo = { + ...defaultLinkInfo, + options: { ...DEFAULT_URL_DRILLDOWN_OPTIONS, openInNewTab: false }, + }; + render( + + + + ); + const link = await screen.findByTestId('externalLink--foo'); + await userEvent.click(link); + expect(coreServices.application.navigateToUrl).toBeCalledTimes(1); + expect(coreServices.application.navigateToUrl).toBeCalledWith('https://example.com'); + }); + + test('disables link when url validation fails', async () => { + const linkInfo = { + ...defaultLinkInfo, + destination: 'file://buzz', + }; + render( + + + + ); + const link = await screen.findByTestId('externalLink--foo--error'); + expect(link).toBeDisabled(); + /** + * TODO: We should test the tooltip content, but the component is disabled + * so it has pointer-events: none. This means we can not use userEvent.hover(). + * See https://testing-library.com/docs/ecosystem-user-event#pointer-events-options + */ + }); +}); diff --git a/src/plugins/links/public/components/external_link/external_link_component.tsx b/src/plugins/links/public/components/external_link/external_link_component.tsx new file mode 100644 index 0000000000000..9c2231fe6b711 --- /dev/null +++ b/src/plugins/links/public/components/external_link/external_link_component.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo, useState } from 'react'; + +import { + UrlDrilldownOptions, + DEFAULT_URL_DRILLDOWN_OPTIONS, +} from '@kbn/ui-actions-enhanced-plugin/public'; +import { EuiListGroupItem } from '@elastic/eui'; + +import { validateUrl } from './external_link_tools'; +import { coreServices } from '../../services/kibana_services'; +import { Link, LinksLayoutType, LINKS_VERTICAL_LAYOUT } from '../../../common/content_management'; + +export const ExternalLinkComponent = ({ + link, + layout, +}: { + link: Link; + layout: LinksLayoutType; +}) => { + const [error, setError] = useState(); + + const linkOptions = useMemo(() => { + return { + ...DEFAULT_URL_DRILLDOWN_OPTIONS, + ...link.options, + } 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) + : link.destination; + }, [linkOptions, link.destination]); + + const id = `externalLink--${link.id}`; + + return ( + { + if (!destination) return; + + /** Only use `navigateToUrl` if we **aren't** opening in a new window/tab; otherwise, just use default href handling */ + const modifiedClick = event.ctrlKey || event.metaKey || event.shiftKey; + if (!modifiedClick) { + event.preventDefault(); + if (linkOptions.openInNewTab) { + window.open(destination, '_blank'); + } else { + await coreServices.application.navigateToUrl(destination); + } + } + }} + /> + ); +}; diff --git a/src/plugins/links/public/components/external_link/external_link_destination_picker.tsx b/src/plugins/links/public/components/external_link/external_link_destination_picker.tsx new file mode 100644 index 0000000000000..30aca970a0783 --- /dev/null +++ b/src/plugins/links/public/components/external_link/external_link_destination_picker.tsx @@ -0,0 +1,85 @@ +/* + * 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 useMount from 'react-use/lib/useMount'; +import useUnmount from 'react-use/lib/useUnmount'; +import React, { useState } from 'react'; + +import { EuiFieldText } from '@elastic/eui'; + +import { ExternalLinkStrings } from './external_link_strings'; +import { validateUrl } from './external_link_tools'; + +export const ExternalLinkDestinationPicker = ({ + onDestinationPicked, + setDestinationError, + initialSelection, + onUnmount, + ...other +}: { + initialSelection?: string; + onUnmount: (destination: string) => void; + onDestinationPicked: (destination?: string) => void; + setDestinationError: (error: string | undefined) => void; +}) => { + const [validUrl, setValidUrl] = useState(true); + const [currentUrl, setCurrentUrl] = useState(initialSelection ?? ''); + + useMount(() => { + if (initialSelection) { + const { valid, message } = validateUrl(initialSelection); + + if (!valid) { + setValidUrl(false); + setDestinationError(message); + onDestinationPicked(undefined); // prevent re-saving an invalid link + } else { + onDestinationPicked(initialSelection); + } + } + }); + + useUnmount(() => { + /** Save the current selection so we can re-populate it if we switch back to this link editor */ + onUnmount(currentUrl); + }); + + /* {...other} is needed so all inner elements are treated as part of the form */ + return ( +
    + { + const url = event.target.value; + setCurrentUrl(url); + + if (url === '') { + /* no need to validate the empty string - not an error, but also not a valid destination */ + setValidUrl(true); + onDestinationPicked(undefined); + setDestinationError(undefined); + return; + } + + const { valid, message } = validateUrl(url); + setValidUrl(valid); + if (valid) { + onDestinationPicked(url); + setDestinationError(undefined); + } else { + onDestinationPicked(undefined); + setDestinationError(message); + } + }} + data-test-subj="links--linkEditor--externalLink--input" + /> +
    + ); +}; diff --git a/src/plugins/links/public/components/external_link/external_link_strings.ts b/src/plugins/links/public/components/external_link/external_link_strings.ts new file mode 100644 index 0000000000000..e2460347afea8 --- /dev/null +++ b/src/plugins/links/public/components/external_link/external_link_strings.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 { i18n } from '@kbn/i18n'; + +export const ExternalLinkStrings = { + getType: () => + i18n.translate('links.externalLink.type', { + defaultMessage: 'External URL', + }), + getDisplayName: () => + i18n.translate('links.externalLink.displayName', { + defaultMessage: 'URL', + }), + getDescription: () => + i18n.translate('links.externalLink.description', { + defaultMessage: 'Go to URL', + }), + getPlaceholder: () => + i18n.translate('links.externalLink.editor.placeholder', { + defaultMessage: 'Enter external URL', + }), + getUrlFormatError: () => + i18n.translate('links.externalLink.editor.urlFormatError', { + defaultMessage: 'Invalid format. Example: {exampleUrl}', + values: { + exampleUrl: 'https://elastic.co/', + }, + }), + getDisallowedUrlError: () => + i18n.translate('links.externalLink.editor.disallowedUrlError', { + defaultMessage: + 'This URL is not allowed by your administrator. Refer to "externalUrl.policy" configuration.', + }), +}; diff --git a/src/plugins/links/public/components/external_link/external_link_tools.ts b/src/plugins/links/public/components/external_link/external_link_tools.ts new file mode 100644 index 0000000000000..4048f1c0f5ccd --- /dev/null +++ b/src/plugins/links/public/components/external_link/external_link_tools.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { urlDrilldownValidateUrl } from '@kbn/ui-actions-enhanced-plugin/public'; +import { coreServices } from '../../services/kibana_services'; +import { ExternalLinkStrings } from './external_link_strings'; + +/** + * + * @param url The URl to validate + * @returns Whether or not the URL is valid; if it is not, it will also return the reason it is invalid via the `message` + */ +export const validateUrl = (url: string): { valid: boolean; message?: string } => { + try { + /** This check will throw an error on invalid format, so catch it below */ + const allowedUrl = coreServices.http.externalUrl.validateUrl(url); + + if (allowedUrl === null) { + return { valid: false, message: ExternalLinkStrings.getDisallowedUrlError() }; + } + const validatedUrl = urlDrilldownValidateUrl(url); + if (!validatedUrl.isValid) { + throw new Error(); // will be caught below + } + } catch { + return { valid: false, message: ExternalLinkStrings.getUrlFormatError() }; + } + + return { valid: true }; +}; diff --git a/src/plugins/links/public/components/links_component.scss b/src/plugins/links/public/components/links_component.scss new file mode 100644 index 0000000000000..ecd801492b9e4 --- /dev/null +++ b/src/plugins/links/public/components/links_component.scss @@ -0,0 +1,58 @@ +.linksComponent { + + .linksPanelLink { + max-width: fit-content; // added this so that the error tooltip shows up **right beside** the link label + + &.dashboardLinkError { + &.dashboardLinkError--noLabel .euiListGroupItem__button { + font-style: italic; + } + + .dashboardLinkIcon { + margin-right: $euiSizeS; + } + } + + &.linkCurrent { + border-radius: 0; + .euiListGroupItem__text { + cursor: default; + color: $euiColorPrimary; + } + } + } + + .verticalLayoutWrapper { + gap: $euiSizeXS; + .linksPanelLink { + &.linkCurrent { + &::before { + content: ''; + position: absolute; + width: .5 * $euiSizeXS; + height: 75%; + background-color: $euiColorPrimary; + } + } + } + } + + .horizontalLayoutWrapper { + height: 100%; + display: flex; + flex-wrap: nowrap; + align-items: center; + flex-direction: row; + + .linksPanelLink { + &.linkCurrent { + padding: 0 $euiSizeS; + + .euiListGroupItem__text { + box-shadow: $euiColorPrimary 0 (-.5 * $euiSizeXS) inset; + padding-inline: 0; + } + } + } + } +} diff --git a/src/plugins/links/public/components/links_component.tsx b/src/plugins/links/public/components/links_component.tsx new file mode 100644 index 0000000000000..9400dc9fe7308 --- /dev/null +++ b/src/plugins/links/public/components/links_component.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo } from 'react'; +import { EuiListGroup, EuiPanel } from '@elastic/eui'; +import { useLinks } from '../embeddable/links_embeddable'; +import { ExternalLinkComponent } from './external_link/external_link_component'; +import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; +import { memoizedGetOrderedLinkList } from '../editor/links_editor_tools'; +import { + DASHBOARD_LINK_TYPE, + LINKS_HORIZONTAL_LAYOUT, + LINKS_VERTICAL_LAYOUT, +} from '../../common/content_management'; + +import './links_component.scss'; + +export const LinksComponent = () => { + const linksEmbeddable = useLinks(); + const links = linksEmbeddable.select((state) => state.componentState.links); + const layout = linksEmbeddable.select((state) => state.componentState.layout); + + const orderedLinks = useMemo(() => { + if (!links) return []; + return memoizedGetOrderedLinkList(links); + }, [links]); + + const linkItems: { [id: string]: { id: string; content: JSX.Element } } = useMemo(() => { + return (links ?? []).reduce((prev, currentLink) => { + return { + ...prev, + [currentLink.id]: { + id: currentLink.id, + content: + currentLink.type === DASHBOARD_LINK_TYPE ? ( + + ) : ( + + ), + }, + }; + }, {}); + }, [links, layout]); + + return ( + + + {orderedLinks.map((link) => linkItems[link.id].content)} + + + ); +}; diff --git a/src/plugins/links/public/components/links_strings.ts b/src/plugins/links/public/components/links_strings.ts new file mode 100644 index 0000000000000..4756bc28e3bdd --- /dev/null +++ b/src/plugins/links/public/components/links_strings.ts @@ -0,0 +1,140 @@ +/* + * 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 LinksStrings = { + getDescription: () => + i18n.translate('links.description', { + defaultMessage: 'Use links to navigate to commonly used dashboards and websites.', + }), + editor: { + getAddButtonLabel: () => + i18n.translate('links.editor.addButtonLabel', { + defaultMessage: 'Add link', + }), + getUpdateButtonLabel: () => + i18n.translate('links.editor.updateButtonLabel', { + defaultMessage: 'Update link', + }), + getEditLinkTitle: () => + i18n.translate('links.editor.editLinkTitle', { + defaultMessage: 'Edit link', + }), + getDeleteLinkTitle: () => + i18n.translate('links.editor.deleteLinkTitle', { + defaultMessage: 'Delete link', + }), + getCancelButtonLabel: () => + i18n.translate('links.editor.cancelButtonLabel', { + defaultMessage: 'Close', + }), + panelEditor: { + getTechnicalPreviewTooltip: () => + i18n.translate('links.panelEditor.technicalPreviewTooltip', { + defaultMessage: + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', + }), + getTechnicalPreviewLabel: () => + i18n.translate('links.panelEditor.technicalPreviewLabel', { + defaultMessage: 'Technical preview', + }), + getLinksTitle: () => + i18n.translate('links.panelEditor.linksTitle', { + defaultMessage: 'Links', + }), + getEmptyLinksMessage: () => + i18n.translate('links.panelEditor.emptyLinksMessage', { + defaultMessage: "You haven't added any links yet.", + }), + getEmptyLinksTooltip: () => + i18n.translate('links.panelEditor.emptyLinksTooltip', { + defaultMessage: 'Add one or more links.', + }), + getCreateFlyoutTitle: () => + i18n.translate('links.panelEditor.createFlyoutTitle', { + defaultMessage: 'Create links panel', + }), + getEditFlyoutTitle: () => + i18n.translate('links.panelEditor.editFlyoutTitle', { + defaultMessage: 'Edit links panel', + }), + getSaveButtonLabel: () => + i18n.translate('links.panelEditor.saveButtonLabel', { + defaultMessage: 'Save', + }), + getSaveToLibrarySwitchLabel: () => + i18n.translate('links.panelEditor.saveToLibrarySwitchLabel', { + defaultMessage: 'Save to library', + }), + getSaveToLibrarySwitchTooltip: () => + i18n.translate('links.panelEditor.saveToLibrarySwitchTooltip', { + defaultMessage: + 'Save this links panel to the library so you can easily add it to other dashboards.', + }), + getTitleInputLabel: () => + i18n.translate('links.panelEditor.titleInputLabel', { + defaultMessage: 'Title', + }), + getBrokenDashboardLinkAriaLabel: () => + i18n.translate('links.panelEditor.brokenDashboardLinkAriaLabel', { + defaultMessage: 'Broken dashboard link', + }), + getDragHandleAriaLabel: () => + i18n.translate('links.panelEditor.dragHandleAriaLabel', { + defaultMessage: 'Link drag handle', + }), + getLayoutSettingsTitle: () => + i18n.translate('links.panelEditor.layoutSettingsTitle', { + defaultMessage: 'Layout', + }), + getLayoutSettingsLegend: () => + i18n.translate('links.panelEditor.layoutSettingsLegend', { + defaultMessage: 'Choose how to display your links.', + }), + getHorizontalLayoutLabel: () => + i18n.translate('links.editor.horizontalLayout', { + defaultMessage: 'Horizontal', + }), + getVerticalLayoutLabel: () => + i18n.translate('links.editor.verticalLayout', { + defaultMessage: 'Vertical', + }), + getErrorDuringSaveToastTitle: () => + i18n.translate('links.editor.unableToSaveToastTitle', { + defaultMessage: 'Error saving Link panel', + }), + }, + linkEditor: { + getGoBackAriaLabel: () => + i18n.translate('links.linkEditor.goBackAriaLabel', { + defaultMessage: 'Go back to panel editor.', + }), + getLinkTypePickerLabel: () => + i18n.translate('links.linkEditor.linkTypeFormLabel', { + defaultMessage: 'Go to', + }), + getLinkDestinationLabel: () => + i18n.translate('links.linkEditor.linkDestinationLabel', { + defaultMessage: 'Choose destination', + }), + getLinkTextLabel: () => + i18n.translate('links.linkEditor.linkTextLabel', { + defaultMessage: 'Text', + }), + getLinkTextPlaceholder: () => + i18n.translate('links.linkEditor.linkTextPlaceholder', { + defaultMessage: 'Enter text for link', + }), + getLinkOptionsLabel: () => + i18n.translate('links.linkEditor.linkOptionsLabel', { + defaultMessage: 'Options', + }), + }, + }, +}; diff --git a/src/plugins/links/public/components/tooltip_wrapper.tsx b/src/plugins/links/public/components/tooltip_wrapper.tsx new file mode 100644 index 0000000000000..a477c62d3bd9c --- /dev/null +++ b/src/plugins/links/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/links/public/content_management/duplicate_title_check.ts b/src/plugins/links/public/content_management/duplicate_title_check.ts new file mode 100644 index 0000000000000..53d93502d7728 --- /dev/null +++ b/src/plugins/links/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 { linksClient } from './links_content_management_client'; + +const rejectErrorMessage = i18n.translate('links.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 linksClient.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/links/public/content_management/index.ts b/src/plugins/links/public/content_management/index.ts new file mode 100644 index 0000000000000..c7bc84b8f6b80 --- /dev/null +++ b/src/plugins/links/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 { linksClient } from './links_content_management_client'; +export { checkForDuplicateTitle } from './duplicate_title_check'; diff --git a/src/plugins/links/public/content_management/links_content_management_client.ts b/src/plugins/links/public/content_management/links_content_management_client.ts new file mode 100644 index 0000000000000..777fd8731d691 --- /dev/null +++ b/src/plugins/links/public/content_management/links_content_management_client.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SearchQuery } from '@kbn/content-management-plugin/common'; +import type { LinksCrudTypes } from '../../common/content_management'; +import { CONTENT_ID as contentTypeId } from '../../common'; +import { contentManagement } from '../services/kibana_services'; + +const get = async (id: string) => { + return contentManagement.client.get({ + contentTypeId, + id, + }); +}; + +const create = async ({ data, options }: Omit) => { + const res = await contentManagement.client.create< + LinksCrudTypes['CreateIn'], + LinksCrudTypes['CreateOut'] + >({ + contentTypeId, + data, + options, + }); + return res; +}; + +const update = async ({ id, data, options }: Omit) => { + const res = await contentManagement.client.update< + LinksCrudTypes['UpdateIn'], + LinksCrudTypes['UpdateOut'] + >({ + contentTypeId, + id, + data, + options, + }); + return res; +}; + +const deleteLinks = async (id: string) => { + await contentManagement.client.delete({ + contentTypeId, + id, + }); +}; + +const search = async (query: SearchQuery = {}, options?: LinksCrudTypes['SearchOptions']) => { + return contentManagement.client.search({ + contentTypeId, + query, + options, + }); +}; + +export const linksClient = { + get, + create, + update, + delete: deleteLinks, + search, +}; diff --git a/src/plugins/links/public/content_management/save_to_library.tsx b/src/plugins/links/public/content_management/save_to_library.tsx new file mode 100644 index 0000000000000..6bb00217224cb --- /dev/null +++ b/src/plugins/links/public/content_management/save_to_library.tsx @@ -0,0 +1,88 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + showSaveModal, + OnSaveProps, + 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'; + +const modalTitle = i18n.translate('links.contentManagement.saveModalTitle', { + defaultMessage: `Save {contentId} panel to library`, + values: { + contentId: CONTENT_ID, + }, +}); + +export const runSaveToLibrary = async ( + newAttributes: LinksAttributes, + initialInput: LinksInput +): 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 getLinksAttributeService().wrapAttributes( + stateToSave, + true, + initialInput + )) as unknown as LinksByReferenceInput; + + resolve(updatedInput); + return { id: updatedInput.savedObjectId }; + }; + + const saveModal = ( + resolve(undefined)} + title={newAttributes.title} + customModalTitle={modalTitle} + description={newAttributes.description} + showDescription + showCopyOnSave={false} + objectType={CONTENT_ID} + /> + ); + showSaveModal(saveModal); + }); +}; diff --git a/src/plugins/links/public/editor/links_editor_tools.tsx b/src/plugins/links/public/editor/links_editor_tools.tsx new file mode 100644 index 0000000000000..780ef5fd21679 --- /dev/null +++ b/src/plugins/links/public/editor/links_editor_tools.tsx @@ -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 { 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; + } +); diff --git a/src/plugins/links/public/editor/open_editor_flyout.tsx b/src/plugins/links/public/editor/open_editor_flyout.tsx new file mode 100644 index 0000000000000..1c722a484eb1d --- /dev/null +++ b/src/plugins/links/public/editor/open_editor_flyout.tsx @@ -0,0 +1,130 @@ +/* + * 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 { withSuspense } from '@kbn/shared-ux-utility'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; +import { tracksOverlays } from '@kbn/embeddable-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { LinksInput, LinksByReferenceInput, LinksEditorFlyoutReturn } from '../embeddable/types'; +import { coreServices } from '../services/kibana_services'; +import { runSaveToLibrary } from '../content_management/save_to_library'; +import { Link, LinksLayoutType } from '../../common/content_management'; +import { getLinksAttributeService } from '../services/attribute_service'; + +const LazyLinksEditor = React.lazy(() => import('../components/editor/links_editor')); + +const LinksEditor = withSuspense( + LazyLinksEditor, + + + +); + +/** + * @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 = tracksOverlays(parentDashboard) ? parentDashboard : undefined; + + if (!initialLinks) { + /** + * When creating a new links panel, the tooltip from the "Add panel" popover interacts badly with the flyout + * and can cause a "double opening" animation if the flyout opens before the tooltip has time to unmount; so, + * when creating a new links panel, we need to slow down the process a little bit so that the tooltip has time + * to disappear before we try to open the flyout. + * + * This does not apply to editing existing links panels, since there is no tooltip for this action. + */ + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + return new Promise((resolve, reject) => { + const onSaveToLibrary = async (newLinks: Link[], newLayout: LinksLayoutType) => { + const newAttributes = { + ...attributes, + 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 (overlayTracker) overlayTracker.clearOverlays(); + }; + + const onAddToDashboard = (newLinks: Link[], newLayout: LinksLayoutType) => { + const newAttributes = { + ...attributes, + 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(); + if (overlayTracker) overlayTracker.clearOverlays(); + }; + + const onCancel = () => { + reject(); + if (overlayTracker) overlayTracker.clearOverlays(); + }; + + const editorFlyout = coreServices.overlays.openFlyout( + toMountPoint( + , + { theme: coreServices.theme, i18n: coreServices.i18n } + ), + { + maxWidth: 720, + ownFocus: true, + outsideClickCloses: false, + onClose: onCancel, + className: 'linksPanelEditor', + 'data-test-subj': 'links--panelEditor--flyout', + } + ); + + if (overlayTracker) overlayTracker.openOverlay(editorFlyout); + }); +} diff --git a/src/plugins/links/public/editor/open_link_editor_flyout.tsx b/src/plugins/links/public/editor/open_link_editor_flyout.tsx new file mode 100644 index 0000000000000..85ecb33afaab4 --- /dev/null +++ b/src/plugins/links/public/editor/open_link_editor_flyout.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; +import { 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'; + +export interface LinksEditorProps { + link?: Link; + parentDashboard?: DashboardContainer; + ref: React.RefObject; +} + +/** + * This editor has no context about other links, so it cannot determine order; order will be determined + * by the **caller** (i.e. the panel editor, which contains the context about **all links**) + */ +export type UnorderedLink = Omit; + +/** + * @throws in case user cancels + */ +export async function openLinkEditorFlyout({ + ref, + link, + parentDashboard, +}: LinksEditorProps): Promise { + const unmountFlyout = async () => { + if (ref.current) { + ref.current.children[1].className = 'linkEditor out'; + } + await new Promise(() => { + // wait for close animation before unmounting + setTimeout(() => { + if (ref.current) ReactDOM.unmountComponentAtNode(ref.current); + }, 180); + }); + }; + + return new Promise((resolve, reject) => { + const onSave = async (newLink: UnorderedLink) => { + resolve(newLink); + await unmountFlyout(); + }; + + const onCancel = async () => { + reject(); + await unmountFlyout(); + }; + + ReactDOM.render( + + + , + ref.current + ); + }).catch(() => { + // on reject (i.e. on cancel), just return the original list of links + return undefined; + }); +} diff --git a/src/plugins/links/public/embeddable/index.ts b/src/plugins/links/public/embeddable/index.ts new file mode 100644 index 0000000000000..ab89b768f1285 --- /dev/null +++ b/src/plugins/links/public/embeddable/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { 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.tsx b/src/plugins/links/public/embeddable/links_embeddable.tsx new file mode 100644 index 0000000000000..863bda323c39b --- /dev/null +++ b/src/plugins/links/public/embeddable/links_embeddable.tsx @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { createContext, useContext } from 'react'; +import { Subscription, distinctUntilChanged, skip } from 'rxjs'; +import deepEqual from 'fast-deep-equal'; + +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 { linksReducers } from './links_reducers'; +import { LinksByReferenceInput, LinksByValueInput, LinksReduxState } from './types'; +import { LinksComponent } from '../components/links_component'; +import { LinksInput, LinksOutput } from './types'; +import { LinksAttributes } from '../../common/content_management'; +import { CONTENT_ID } from '../../common'; + +export const LinksContext = createContext(null); +export const useLinks = (): LinksEmbeddable => { + const linksEmbeddable = useContext(LinksContext); + if (linksEmbeddable == null) { + throw new Error('useLinks must be used inside LinksContext.'); + } + return linksEmbeddable!; +}; + +type LinksReduxEmbeddableTools = ReduxEmbeddableTools; + +export interface LinksConfig { + editable: boolean; +} + +export class LinksEmbeddable + extends Embeddable + implements ReferenceOrValueEmbeddable +{ + public readonly type = CONTENT_ID; + deferEmbeddableLoad = true; + + private isDestroyed?: boolean; + private subscriptions: Subscription = new Subscription(); + + // state management + /** + * TODO: Keep track of the necessary state without the redux embeddable tools; it's kind of overkill here. + * Related issue: https://github.com/elastic/kibana/issues/167577 + */ + public select: LinksReduxEmbeddableTools['select']; + public getState: LinksReduxEmbeddableTools['getState']; + public dispatch: LinksReduxEmbeddableTools['dispatch']; + public onStateChange: LinksReduxEmbeddableTools['onStateChange']; + + private cleanupStateTools: () => void; + + constructor( + reduxToolsPackage: ReduxToolsPackage, + config: LinksConfig, + initialInput: LinksInput, + private attributeService: AttributeService, + parent?: DashboardContainer + ) { + super( + initialInput, + { + editable: config.editable, + editableWithExplicitInput: true, + }, + parent + ); + + /** Build redux embeddable tools */ + const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools< + LinksReduxState, + typeof linksReducers + >({ + embeddable: this, + reducers: linksReducers, + initialComponentState: { + title: '', + }, + }); + + this.select = reduxEmbeddableTools.select; + this.getState = reduxEmbeddableTools.getState; + this.dispatch = reduxEmbeddableTools.dispatch; + this.cleanupStateTools = reduxEmbeddableTools.cleanup; + this.onStateChange = reduxEmbeddableTools.onStateChange; + + this.initializeSavedLinks() + .then(() => this.setInitializationFinished()) + .catch((e: Error) => this.onFatalError(e)); + + // By-value panels should update the componentState when input changes + this.subscriptions.add( + this.getInput$() + .pipe(distinctUntilChanged(deepEqual), skip(1)) + .subscribe(async () => await this.initializeSavedLinks()) + ); + } + + private async initializeSavedLinks() { + const { attributes } = await this.attributeService.unwrapAttributes(this.getInput()); + if (this.isDestroyed) return; + + this.dispatch.setAttributes(attributes); + + await this.initializeOutput(); + } + + private async initializeOutput() { + const attributes = this.getState().componentState; + const { title, description } = this.getInput(); + this.updateOutput({ + defaultTitle: attributes.title, + defaultDescription: attributes.description, + title: title ?? attributes.title, + description: description ?? attributes.description, + }); + } + + 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 componentState + this.initializeSavedLinks(); + 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/links/public/embeddable/links_embeddable_factory.test.ts b/src/plugins/links/public/embeddable/links_embeddable_factory.test.ts new file mode 100644 index 0000000000000..427827a1ace4b --- /dev/null +++ b/src/plugins/links/public/embeddable/links_embeddable_factory.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LinksFactoryDefinition } from './links_embeddable_factory'; +import { LinksInput } from './types'; + +describe('linksFactory', () => { + test('returns an empty object when not given proper meta information', () => { + const linksFactory = new LinksFactoryDefinition(); + const settings = linksFactory.getPanelPlacementSettings({} as unknown as LinksInput, {}); + expect(settings.height).toBeUndefined(); + expect(settings.width).toBeUndefined(); + expect(settings.strategy).toBeUndefined(); + }); + + test('returns a horizontal layout', () => { + const linksFactory = new LinksFactoryDefinition(); + const settings = linksFactory.getPanelPlacementSettings({} as unknown as LinksInput, { + layout: 'horizontal', + links: [], + }); + expect(settings.height).toBe(4); + expect(settings.width).toBe(48); + expect(settings.strategy).toBe('placeAtTop'); + }); + + test('returns a vertical layout with the appropriate height', () => { + const linksFactory = new LinksFactoryDefinition(); + const settings = linksFactory.getPanelPlacementSettings({} as unknown as LinksInput, { + layout: 'vertical', + links: [ + { type: 'dashboardLink', destination: 'superDashboard1' }, + { type: 'dashboardLink', destination: 'superDashboard2' }, + { type: 'dashboardLink', destination: 'superDashboard3' }, + ], + }); + expect(settings.height).toBe(7); // 4 base plus 3 for each link. + expect(settings.width).toBe(8); + expect(settings.strategy).toBe('placeAtTop'); + }); +}); diff --git a/src/plugins/links/public/embeddable/links_embeddable_factory.ts b/src/plugins/links/public/embeddable/links_embeddable_factory.ts new file mode 100644 index 0000000000000..e0502d34a742c --- /dev/null +++ b/src/plugins/links/public/embeddable/links_embeddable_factory.ts @@ -0,0 +1,161 @@ +/* + * 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 { + EmbeddableFactory, + EmbeddableFactoryDefinition, + ErrorEmbeddable, +} from '@kbn/embeddable-plugin/public'; +import { + MigrateFunctionsObject, + GetMigrationFunctionObjectFn, +} from '@kbn/kibana-utils-plugin/common'; +import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; +import { DASHBOARD_GRID_COLUMN_COUNT } from '@kbn/dashboard-plugin/public'; +import { UiActionsPresentableGrouping } from '@kbn/ui-actions-plugin/public'; +import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { IProvidesPanelPlacementSettings } from '@kbn/dashboard-plugin/public/dashboard_container/component/panel_placement/types'; + +import { + coreServices, + presentationUtil, + untilPluginStartServicesReady, +} from '../services/kibana_services'; +import { extract, inject } from '../../common/embeddable'; +import type { LinksEmbeddable } from './links_embeddable'; +import { LinksStrings } from '../components/links_strings'; +import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; +import { LinksAttributes } from '../../common/content_management'; +import { getLinksAttributeService } from '../services/attribute_service'; +import { LinksInput, LinksByReferenceInput, LinksEditorFlyoutReturn } 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, IProvidesPanelPlacementSettings +{ + latestVersion?: string | undefined; + telemetry?: + | ((state: EmbeddableStateWithType, stats: Record) => Record) + | undefined; + migrations?: MigrateFunctionsObject | GetMigrationFunctionObjectFn | undefined; + grouping?: UiActionsPresentableGrouping | undefined; + public readonly type = CONTENT_ID; + + public readonly isContainerType = false; + + public readonly savedObjectMetaData = { + name: APP_NAME, + type: CONTENT_ID, + getIconForSavedObject: () => APP_ICON, + }; + + public getPanelPlacementSettings: IProvidesPanelPlacementSettings< + LinksInput, + LinksAttributes | unknown + >['getPanelPlacementSettings'] = (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: 'placeAtTop' }; + }; + + public async isEditable() { + await untilPluginStartServicesReady(); + return Boolean(coreServices.application.capabilities.dashboard?.showWriteControls); + } + + public canCreateNew() { + return presentationUtil.labsService.isProjectEnabled('labs:dashboard:linksPanel'); + } + + 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 reduxEmbeddablePackage = await lazyLoadReduxToolsPackage(); + const { LinksEmbeddable } = await import('./links_embeddable'); + const editable = await this.isEditable(); + + return new LinksEmbeddable( + reduxEmbeddablePackage, + { editable }, + { ...getDefaultLinksInput(), ...initialInput }, + getLinksAttributeService(), + parent + ); + } + + public async getExplicitInput( + initialInput: LinksInput, + parent?: DashboardContainer + ): Promise { + if (!parent) return { newInput: {} }; + + 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/links_reducers.ts b/src/plugins/links/public/embeddable/links_reducers.ts new file mode 100644 index 0000000000000..659b19058adbb --- /dev/null +++ b/src/plugins/links/public/embeddable/links_reducers.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { WritableDraft } from 'immer/dist/types/types-external'; + +import { PayloadAction } from '@reduxjs/toolkit'; + +import { LinksReduxState } from './types'; +import { LinksAttributes } from '../../common/content_management'; + +export const linksReducers = { + setLoading: (state: WritableDraft, action: PayloadAction) => { + state.output.loading = action.payload; + }, + + setAttributes: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.componentState = { ...action.payload }; + }, +}; diff --git a/src/plugins/links/public/embeddable/types.ts b/src/plugins/links/public/embeddable/types.ts new file mode 100644 index 0000000000000..d16d8431a5601 --- /dev/null +++ b/src/plugins/links/public/embeddable/types.ts @@ -0,0 +1,90 @@ +/* + * 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 new file mode 100644 index 0000000000000..3389cd48f4b67 --- /dev/null +++ b/src/plugins/links/public/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { LinksFactory } from './embeddable'; +export { LinksFactoryDefinition, LinksEmbeddable } from './embeddable'; + +import { LinksPlugin } from './plugin'; + +export function plugin() { + return new LinksPlugin(); +} diff --git a/src/plugins/links/public/mocks.tsx b/src/plugins/links/public/mocks.tsx new file mode 100644 index 0000000000000..6a27185c9b09a --- /dev/null +++ b/src/plugins/links/public/mocks.tsx @@ -0,0 +1,25 @@ +/* + * 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 new file mode 100644 index 0000000000000..7927de88b80e7 --- /dev/null +++ b/src/plugins/links/public/plugin.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { + ContentManagementPublicSetup, + ContentManagementPublicStart, +} from '@kbn/content-management-plugin/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; +import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; + +import { APP_NAME } from '../common'; +import { LinksFactoryDefinition } from './embeddable'; +import { CONTENT_ID, LATEST_VERSION } from '../common'; +import { setKibanaServices } from './services/kibana_services'; + +export interface LinksSetupDependencies { + embeddable: EmbeddableSetup; + contentManagement: ContentManagementPublicSetup; +} + +export interface LinksStartDependencies { + embeddable: EmbeddableStart; + dashboard: DashboardStart; + presentationUtil: PresentationUtilPluginStart; + contentManagement: ContentManagementPublicStart; +} + +export class LinksPlugin + implements Plugin +{ + constructor() {} + + public setup(core: CoreSetup, plugins: LinksSetupDependencies) { + core.getStartServices().then(([_, deps]) => { + plugins.embeddable.registerEmbeddableFactory(CONTENT_ID, new LinksFactoryDefinition()); + + plugins.contentManagement.registry.register({ + id: CONTENT_ID, + version: { + latest: LATEST_VERSION, + }, + name: APP_NAME, + }); + }); + } + + public start(core: CoreStart, plugins: LinksStartDependencies) { + setKibanaServices(core, plugins); + return {}; + } + + public stop() {} +} diff --git a/src/plugins/links/public/services/attribute_service.ts b/src/plugins/links/public/services/attribute_service.ts new file mode 100644 index 0000000000000..bde2ab27c1d15 --- /dev/null +++ b/src/plugins/links/public/services/attribute_service.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { 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 new file mode 100644 index 0000000000000..76acd242f7575 --- /dev/null +++ b/src/plugins/links/public/services/kibana_services.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; + +import { CoreStart } from '@kbn/core/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; +import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; +import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; + +import { LinksStartDependencies } from '../plugin'; + +export let coreServices: CoreStart; +export let dashboardServices: DashboardStart; +export let embeddableService: EmbeddableStart; +export let presentationUtil: PresentationUtilPluginStart; +export let contentManagement: ContentManagementPublicStart; + +const servicesReady$ = new BehaviorSubject(false); + +export const untilPluginStartServicesReady = () => { + if (servicesReady$.value) return Promise.resolve(); + return new Promise((resolve) => { + const subscription = servicesReady$.subscribe((isInitialized) => { + if (isInitialized) { + subscription.unsubscribe(); + resolve(); + } + }); + }); +}; + +export const setKibanaServices = (kibanaCore: CoreStart, deps: LinksStartDependencies) => { + coreServices = kibanaCore; + dashboardServices = deps.dashboard; + embeddableService = deps.embeddable; + presentationUtil = deps.presentationUtil; + contentManagement = deps.contentManagement; + + servicesReady$.next(true); +}; diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/index.ts b/src/plugins/links/server/content_management/index.ts similarity index 86% rename from src/plugins/dashboard/public/dashboard_container/component/panel/index.ts rename to src/plugins/links/server/content_management/index.ts index 015b31ed725d9..82666a940d249 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/index.ts +++ b/src/plugins/links/server/content_management/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { createPanelState } from './create_panel_state'; +export { LinksStorage } from './links_storage'; diff --git a/src/plugins/links/server/content_management/links_storage.ts b/src/plugins/links/server/content_management/links_storage.ts new file mode 100644 index 0000000000000..21a5e4aa0de0d --- /dev/null +++ b/src/plugins/links/server/content_management/links_storage.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Logger } from '@kbn/logging'; +import { SOContentStorage } from '@kbn/content-management-utils'; +import { CONTENT_ID } from '../../common'; +import type { LinksCrudTypes } from '../../common/content_management'; +import { cmServicesDefinition } from '../../common/content_management/cm_services'; + +export class LinksStorage extends SOContentStorage { + constructor({ + logger, + throwOnResultValidationError, + }: { + logger: Logger; + throwOnResultValidationError: boolean; + }) { + super({ + savedObjectType: CONTENT_ID, + cmServicesDefinition, + enableMSearch: true, + allowedSavedObjectAttributes: ['id', 'title', 'description', 'links', 'layout'], + logger, + throwOnResultValidationError, + }); + } +} diff --git a/src/plugins/links/server/index.ts b/src/plugins/links/server/index.ts new file mode 100644 index 0000000000000..c60d084fa66d2 --- /dev/null +++ b/src/plugins/links/server/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { PluginInitializerContext } from '@kbn/core-plugins-server'; +import { LinksServerPlugin } from './plugin'; + +export const plugin = (initContext: PluginInitializerContext) => new LinksServerPlugin(initContext); diff --git a/src/plugins/links/server/plugin.ts b/src/plugins/links/server/plugin.ts new file mode 100644 index 0000000000000..b1a0bfafed763 --- /dev/null +++ b/src/plugins/links/server/plugin.ts @@ -0,0 +1,50 @@ +/* + * 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, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server'; +import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; +import { CONTENT_ID, LATEST_VERSION } from '../common'; +import { LinksAttributes } from '../common/content_management'; +import { LinksStorage } from './content_management'; +import { linksSavedObjectType } from './saved_objects'; + +export class LinksServerPlugin implements Plugin { + private readonly logger: Logger; + + constructor(private initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup( + core: CoreSetup, + plugins: { + contentManagement: ContentManagementServerSetup; + } + ) { + plugins.contentManagement.register({ + id: CONTENT_ID, + storage: new LinksStorage({ + throwOnResultValidationError: this.initializerContext.env.mode.dev, + logger: this.logger.get('storage'), + }), + version: { + latest: LATEST_VERSION, + }, + }); + + core.savedObjects.registerType(linksSavedObjectType); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/links/server/saved_objects/index.ts b/src/plugins/links/server/saved_objects/index.ts new file mode 100644 index 0000000000000..d6303bb2b8b78 --- /dev/null +++ b/src/plugins/links/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 { linksSavedObjectType } from './links'; diff --git a/src/plugins/links/server/saved_objects/links.ts b/src/plugins/links/server/saved_objects/links.ts new file mode 100644 index 0000000000000..b00f49e6d8cae --- /dev/null +++ b/src/plugins/links/server/saved_objects/links.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import 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 linksSavedObjectType: SavedObjectsType = { + name: CONTENT_ID, + indexPattern: ANALYTICS_SAVED_OBJECT_INDEX, + hidden: false, + namespaceType: 'multiple-isolated', + management: { + icon: APP_ICON, + defaultSearchField: 'title', + importableAndExportable: true, + getTitle(obj) { + return obj.attributes.title; + }, + }, + mappings: { + dynamic: false, + properties: { + id: { type: 'text' }, + title: { type: 'text' }, + description: { type: 'text' }, + links: { + dynamic: false, + properties: {}, + }, + }, + }, + migrations: () => { + return {}; + }, +}; diff --git a/src/plugins/links/tsconfig.json b/src/plugins/links/tsconfig.json new file mode 100644 index 0000000000000..ba9b5b67d058f --- /dev/null +++ b/src/plugins/links/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["*.ts", "public/**/*", "common/**/*", "server/**/*", "public/**/*.json"], + "kbn_references": [ + "@kbn/core", + "@kbn/i18n", + "@kbn/dashboard-plugin", + "@kbn/embeddable-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", + "@kbn/ui-actions-enhanced-plugin", + "@kbn/es-query", + "@kbn/share-plugin", + "@kbn/kibana-utils-plugin", + "@kbn/utility-types", + "@kbn/ui-actions-plugin", + "@kbn/logging", + "@kbn/core-plugins-server", + "@kbn/react-kibana-mount", + "@kbn/react-kibana-context-theme" + ], + "exclude": ["target/**/*"] +} diff --git a/src/plugins/presentation_util/common/labs.ts b/src/plugins/presentation_util/common/labs.ts index b900eb7a6e36c..18fd77a364429 100644 --- a/src/plugins/presentation_util/common/labs.ts +++ b/src/plugins/presentation_util/common/labs.ts @@ -10,13 +10,26 @@ import { i18n } from '@kbn/i18n'; export const LABS_PROJECT_PREFIX = 'labs:'; export const DEFER_BELOW_FOLD = `${LABS_PROJECT_PREFIX}dashboard:deferBelowFold` as const; +export const DASHBOARD_LINKS_PANEL = `${LABS_PROJECT_PREFIX}dashboard:linksPanel` as const; export const DASHBOARD_CONTROLS = `${LABS_PROJECT_PREFIX}dashboard:dashboardControls` as const; export const BY_VALUE_EMBEDDABLE = `${LABS_PROJECT_PREFIX}canvas:byValueEmbeddable` as const; -export const projectIDs = [DEFER_BELOW_FOLD, DASHBOARD_CONTROLS, BY_VALUE_EMBEDDABLE] as const; +export const projectIDs = [ + DEFER_BELOW_FOLD, + DASHBOARD_CONTROLS, + BY_VALUE_EMBEDDABLE, + DASHBOARD_LINKS_PANEL, +] as const; export const environmentNames = ['kibana', 'browser', 'session'] as const; export const solutionNames = ['canvas', 'dashboard', 'presentation'] as const; +const technicalPreviewLabel = i18n.translate( + 'presentationUtil.advancedSettings.technicalPreviewLabel', + { + defaultMessage: 'technical preview', + } +); + /** * This is a list of active Labs Projects for the Presentation Team. It is the "source of truth" for all projects * provided to users of our solutions in Kibana. @@ -50,6 +63,23 @@ export const projects: { [ID in ProjectID]: ProjectConfig & { id: ID } } = { }), solutions: ['dashboard'], }, + [DASHBOARD_LINKS_PANEL]: { + id: DASHBOARD_LINKS_PANEL, + isActive: true, + isDisplayed: true, + environments: ['kibana', 'browser', 'session'], + name: i18n.translate('presentationUtil.labs.enableLinksPanelProjectName', { + defaultMessage: 'Enable links panel', + }), + description: i18n.translate('presentationUtil.labs.enableLinksPanelProjectDescription', { + defaultMessage: + '{technicalPreviewLabel} Enables the links panel for dashboard, which allows dashboard authors to easily link dashboards together.', + values: { + technicalPreviewLabel: `[${technicalPreviewLabel}]`, + }, + }), + solutions: ['dashboard'], + }, [BY_VALUE_EMBEDDABLE]: { id: BY_VALUE_EMBEDDABLE, isActive: true, diff --git a/src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx b/src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx new file mode 100644 index 0000000000000..a87195b305f1b --- /dev/null +++ b/src/plugins/presentation_util/public/components/dashboard_drilldown_options/dashboard_drilldown_options.tsx @@ -0,0 +1,55 @@ +/* + * 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 { EuiFormRow, EuiSwitch } from '@elastic/eui'; + +import { DashboardDrilldownOptions } from './types'; +import { dashboardDrilldownConfigStrings } from '../../i18n/dashboard_drilldown_config'; + +export interface DashboardDrilldownOptionsProps { + options: DashboardDrilldownOptions; + onOptionChange: (newOptions: Partial) => void; +} + +export const DashboardDrilldownOptionsComponent = ({ + options, + onOptionChange, +}: DashboardDrilldownOptionsProps) => { + return ( + <> + + onOptionChange({ useCurrentFilters: !options.useCurrentFilters })} + data-test-subj="dashboardDrillDownOptions--useCurrentFilters--checkbox" + /> + + + onOptionChange({ useCurrentDateRange: !options.useCurrentDateRange })} + data-test-subj="dashboardDrillDownOptions--useCurrentDateRange--checkbox" + /> + + + onOptionChange({ openInNewTab: !options.openInNewTab })} + data-test-subj="dashboardDrillDownOptions--openInNewTab--checkbox" + /> + + + ); +}; diff --git a/src/plugins/presentation_util/public/components/dashboard_drilldown_options/types.ts b/src/plugins/presentation_util/public/components/dashboard_drilldown_options/types.ts new file mode 100644 index 0000000000000..60d8646b67532 --- /dev/null +++ b/src/plugins/presentation_util/public/components/dashboard_drilldown_options/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type DashboardDrilldownOptions = { + useCurrentFilters: boolean; + useCurrentDateRange: boolean; + openInNewTab: boolean; +}; + +export const DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS: DashboardDrilldownOptions = { + openInNewTab: false, + useCurrentDateRange: true, + useCurrentFilters: true, +}; diff --git a/src/plugins/presentation_util/public/components/index.tsx b/src/plugins/presentation_util/public/components/index.tsx index cff38e8a79d2b..c51f31eedd5bb 100644 --- a/src/plugins/presentation_util/public/components/index.tsx +++ b/src/plugins/presentation_util/public/components/index.tsx @@ -42,6 +42,24 @@ export const LazyDataViewPicker = React.lazy(() => import('./data_view_picker/da export const LazyFieldPicker = React.lazy(() => import('./field_picker/field_picker')); +const LazyDashboardDrilldownOptionsComponent = React.lazy(() => + import('./dashboard_drilldown_options/dashboard_drilldown_options').then( + ({ DashboardDrilldownOptionsComponent }) => ({ + default: DashboardDrilldownOptionsComponent, + }) + ) +); + +export const DashboardDrilldownOptionsComponent = withSuspense( + LazyDashboardDrilldownOptionsComponent, + null +); + +export { + type DashboardDrilldownOptions, + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, +} from './dashboard_drilldown_options/types'; + export { FloatingActions } from './floating_actions/floating_actions'; /** diff --git a/src/plugins/presentation_util/public/i18n/dashboard_drilldown_config.tsx b/src/plugins/presentation_util/public/i18n/dashboard_drilldown_config.tsx new file mode 100644 index 0000000000000..2bb326cb6f841 --- /dev/null +++ b/src/plugins/presentation_util/public/i18n/dashboard_drilldown_config.tsx @@ -0,0 +1,29 @@ +/* + * 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 dashboardDrilldownConfigStrings = { + component: { + getUseCurrentFiltersLabel: () => + i18n.translate( + 'presentationUtil.dashboardDrilldownConfig.components.useCurrentFiltersLabel', + { + defaultMessage: 'Use filters and query from origin dashboard', + } + ), + getUseCurrentDateRange: () => + i18n.translate('presentationUtil.dashboardDrilldownConfig.components.useCurrentDateRange', { + defaultMessage: 'Use date range from origin dashboard', + }), + getOpenInNewTab: () => + i18n.translate('presentationUtil.dashboardDrilldownConfig.components.openInNewTab', { + defaultMessage: 'Open dashboard in new tab', + }), + }, +}; diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 280fc4b979ce0..f2ca659d2da32 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -33,6 +33,9 @@ export { LazyDataViewPicker, LazyFieldPicker, FloatingActions, + type DashboardDrilldownOptions, + DashboardDrilldownOptionsComponent, + DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, } from './components'; export { diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index bc8686101eb21..e2013ff091c76 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -9929,6 +9929,12 @@ "description": "Non-default value of setting." } }, + "labs:dashboard:linksPanel": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "discover:showFieldStatistics": { "type": "boolean", "_meta": { diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts index 4e73e9a43a9e2..b50b1fe96d46a 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts @@ -6,4 +6,7 @@ * Side Public License, v 1. */ -export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config'; +export { + UrlDrilldownCollectConfig, + UrlDrilldownOptionsComponent, +} from './url_drilldown_collect_config'; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts index dcb36cbab257a..48f5fe1fdc8b6 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts @@ -57,7 +57,7 @@ export const txtUrlTemplatePreviewLinkText = i18n.translate( export const txtUrlTemplateOpenInNewTab = i18n.translate( 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.openInNewTabLabel', { - defaultMessage: 'Open in new window', + defaultMessage: 'Open URL in new tab', } ); diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts index fd96f908fda23..02a3097eb9d9e 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.ts @@ -7,4 +7,4 @@ */ export type { UrlDrilldownCollectConfigProps } from './lazy'; -export { UrlDrilldownCollectConfig } from './lazy'; +export { UrlDrilldownCollectConfig, UrlDrilldownOptionsComponent } from './lazy'; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx index eb666d6151ece..cc2b6500de042 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/lazy.tsx @@ -7,6 +7,7 @@ */ import * as React from 'react'; +import { UrlDrilldownOptionsProps } from './url_drilldown_options'; import type { UrlDrilldownCollectConfigProps } from './url_drilldown_collect_config'; const UrlDrilldownCollectConfigLazy = React.lazy(() => @@ -24,3 +25,17 @@ export const UrlDrilldownCollectConfig: React.FC ); }; + +const UrlDrilldownOptionsComponentLazy = React.lazy(() => + import('./url_drilldown_options').then(({ UrlDrilldownOptionsComponent }) => ({ + default: UrlDrilldownOptionsComponent, + })) +); + +export const UrlDrilldownOptionsComponent: React.FC = (props) => { + return ( + + + + ); +}; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx index 8fc2fe3c68c2e..28afcea46e6ca 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx @@ -13,6 +13,7 @@ import { UrlDrilldownCollectConfig } from '../url_drilldown_collect_config'; export const Demo = () => { const [config, onConfig] = React.useState({ openInNewTab: false, + encodeUrl: true, url: { template: '' }, }); diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx index 0e4825dd58e50..0495f2d61063c 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -7,15 +7,7 @@ */ import React, { useRef } from 'react'; -import { - EuiFormRow, - EuiLink, - EuiSwitch, - EuiAccordion, - EuiSpacer, - EuiPanel, - EuiTextColor, -} from '@elastic/eui'; +import { EuiFormRow, EuiLink, EuiAccordion, EuiSpacer, EuiPanel } from '@elastic/eui'; import { monaco } from '@kbn/monaco'; import { UrlTemplateEditor, UrlTemplateEditorVariable } from '@kbn/kibana-react-plugin/public'; import { UrlDrilldownConfig } from '../../types'; @@ -23,12 +15,11 @@ import './index.scss'; import { txtUrlTemplateSyntaxHelpLinkText, txtUrlTemplateLabel, - txtUrlTemplateOpenInNewTab, txtUrlTemplateAdditionalOptions, - txtUrlTemplateEncodeUrl, - txtUrlTemplateEncodeDescription, } from './i18n'; import { VariablePopover } from '../variable_popover'; +import { UrlDrilldownOptionsComponent } from './lazy'; +import { DEFAULT_URL_DRILLDOWN_OPTIONS } from '../../constants'; export interface UrlDrilldownCollectConfigProps { config: UrlDrilldownConfig; @@ -114,31 +105,12 @@ export const UrlDrilldownCollectConfig: React.FC > - - onConfig({ ...config, openInNewTab: !config.openInNewTab })} - data-test-subj="urlDrilldownOpenInNewTab" - /> - - - - {txtUrlTemplateEncodeUrl} - - {txtUrlTemplateEncodeDescription} - - } - checked={config.encodeUrl ?? true} - onChange={() => onConfig({ ...config, encodeUrl: !(config.encodeUrl ?? true) })} - /> - + { + onConfig({ ...config, ...change }); + }} + /> diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx new file mode 100644 index 0000000000000..a0f5da726a13a --- /dev/null +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_options.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiFormRow, EuiSpacer, EuiSwitch, EuiTextColor } from '@elastic/eui'; + +import { + txtUrlTemplateEncodeDescription, + txtUrlTemplateEncodeUrl, + txtUrlTemplateOpenInNewTab, +} from './i18n'; +import { UrlDrilldownOptions } from '../../types'; + +export interface UrlDrilldownOptionsProps { + options: UrlDrilldownOptions; + onOptionChange: (newOptions: Partial) => void; +} + +export const UrlDrilldownOptionsComponent = ({ + options, + onOptionChange, +}: UrlDrilldownOptionsProps) => { + return ( + <> + + onOptionChange({ openInNewTab: !options.openInNewTab })} + data-test-subj="urlDrilldownOpenInNewTab" + /> + + + + {txtUrlTemplateEncodeUrl} + + {txtUrlTemplateEncodeDescription} + + } + checked={options.encodeUrl} + onChange={() => onOptionChange({ encodeUrl: !options.encodeUrl })} + data-test-subj="urlDrilldownEncodeUrl" + /> + + + ); +}; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/constants.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/constants.ts new file mode 100644 index 0000000000000..67e48c74274eb --- /dev/null +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UrlDrilldownOptions } from './types'; + +export const DEFAULT_URL_DRILLDOWN_OPTIONS: UrlDrilldownOptions = { + encodeUrl: true, + openInNewTab: true, +}; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts index 543546132a3a2..4f6ef220a0ba1 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts @@ -6,8 +6,14 @@ * Side Public License, v 1. */ -export type { UrlDrilldownConfig, UrlDrilldownGlobalScope, UrlDrilldownScope } from './types'; -export { UrlDrilldownCollectConfig } from './components'; +export type { + UrlDrilldownScope, + UrlDrilldownConfig, + UrlDrilldownOptions, + UrlDrilldownGlobalScope, +} from './types'; +export { DEFAULT_URL_DRILLDOWN_OPTIONS } from './constants'; +export { UrlDrilldownCollectConfig, UrlDrilldownOptionsComponent } from './components'; export { validateUrlTemplate as urlDrilldownValidateUrlTemplate, validateUrl as urlDrilldownValidateUrl, diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts index 3566b6712c78d..6dc9ee66de9f0 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts @@ -8,8 +8,14 @@ export type UrlDrilldownConfig = { url: { format?: 'handlebars_v1'; template: string }; +} & UrlDrilldownOptions; + +/** + * User-configurable options for URL drilldowns + */ +export type UrlDrilldownOptions = { openInNewTab: boolean; - encodeUrl?: boolean; + encodeUrl: boolean; }; /** diff --git a/src/plugins/ui_actions_enhanced/public/index.ts b/src/plugins/ui_actions_enhanced/public/index.ts index b609c5807a562..c419e6c682734 100644 --- a/src/plugins/ui_actions_enhanced/public/index.ts +++ b/src/plugins/ui_actions_enhanced/public/index.ts @@ -45,11 +45,14 @@ export type { UrlDrilldownConfig, UrlDrilldownGlobalScope, UrlDrilldownScope, + UrlDrilldownOptions, } from './drilldowns/url_drilldown'; export { urlDrilldownCompileUrl, UrlDrilldownCollectConfig, + UrlDrilldownOptionsComponent, urlDrilldownGlobalScopeProvider, urlDrilldownValidateUrl, urlDrilldownValidateUrlTemplate, + DEFAULT_URL_DRILLDOWN_OPTIONS, } from './drilldowns/url_drilldown'; diff --git a/test/functional/apps/dashboard_elements/links/config.ts b/test/functional/apps/dashboard_elements/links/config.ts new file mode 100644 index 0000000000000..f6692ef0d0772 --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/config.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const commonConfig = await readConfigFile(require.resolve('../../../../common/config.js')); + const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js')); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('.')], + junit: { + reportName: 'Dashboard Elements - Links panel tests', + }, + kbnTestServer: { + ...commonConfig.get('kbnTestServer'), + serverArgs: [ + ...commonConfig.get('kbnTestServer.serverArgs'), + `--externalUrl.policy=${JSON.stringify([ + { + allow: false, + host: 'danger.example.com', + }, + { + allow: true, + host: 'example.com', + }, + ])}`, + ], + }, + }; +} diff --git a/test/functional/apps/dashboard_elements/links/index.ts b/test/functional/apps/dashboard_elements/links/index.ts new file mode 100644 index 0000000000000..6c2b1372f07e1 --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/index.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile, getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const security = getService('security'); + + const { dashboard } = getPageObjects(['dashboardControls', 'dashboard']); + + async function setup() { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + + await dashboard.navigateToApp(); + await dashboard.preserveCrossAppState(); + } + + async function teardown() { + await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); + await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); + } + + describe('links panel', function () { + before(setup); + after(teardown); + loadTestFile(require.resolve('./links_create_edit')); + loadTestFile(require.resolve('./links_navigation')); + }); +} diff --git a/test/functional/apps/dashboard_elements/links/links_create_edit.ts b/test/functional/apps/dashboard_elements/links/links_create_edit.ts new file mode 100644 index 0000000000000..4a6e94c656bac --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/links_create_edit.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const deployment = getService('deployment'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const { dashboardLinks, dashboard, common, header } = getPageObjects([ + 'dashboardLinks', + 'dashboard', + 'common', + 'header', + ]); + + async function createSomeLinks() { + await dashboardLinks.addExternalLink( + `${deployment.getHostPort()}/app/foo`, + true, + true, + 'Link to new tab' + ); + await dashboardLinks.addExternalLink(`${deployment.getHostPort()}/app/bar`, false, true); + + await dashboardLinks.addDashboardLink(DASHBOARD_NAME); + await dashboardLinks.addDashboardLink('links 001'); + } + + const DASHBOARD_NAME = 'Test Links panel'; + const LINKS_PANEL_NAME = 'Some links'; + + describe('links panel create and edit', () => { + describe('creation', async () => { + before(async () => { + await dashboard.navigateToApp(); + await dashboard.preserveCrossAppState(); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await dashboard.saveDashboard(DASHBOARD_NAME, { exitFromEditMode: false }); + await dashboard.loadSavedDashboard(DASHBOARD_NAME); + await dashboard.switchToEditMode(); + }); + + it('can not add an external link that violates externalLinks.policy', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + + await dashboardLinks.setExternalUrlInput('https://danger.example.com'); + expect(await testSubjects.exists('links--linkDestination--error')).to.be(true); + await dashboardLinks.clickLinkEditorCloseButton(); + await dashboardLinks.clickPanelEditorCloseButton(); + }); + + it('can create a new by-reference links panel', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + + await createSomeLinks(); + await dashboardLinks.toggleSaveByReference(true); + await dashboardLinks.clickPanelEditorSaveButton(); + + await testSubjects.exists('savedObjectSaveModal'); + await testSubjects.setValue('savedObjectTitle', LINKS_PANEL_NAME); + await testSubjects.click('confirmSaveSavedObjectButton'); + await common.waitForSaveModalToClose(); + await testSubjects.exists('addObjectToDashboardSuccess'); + + expect(await testSubjects.existOrFail('links--component')); + expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(4); + await dashboard.clickDiscardChanges(); + }); + + it('can create a new by-value links panel', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('links'); + await dashboardLinks.setLayout('horizontal'); + await createSomeLinks(); + await dashboardLinks.toggleSaveByReference(false); + await dashboardLinks.clickPanelEditorSaveButton(); + await testSubjects.exists('addObjectToDashboardSuccess'); + + expect(await testSubjects.existOrFail('links--component')); + expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(4); + await dashboard.clickDiscardChanges(); + }); + }); + + describe('editing', () => { + it('can reorder links in an existing panel', async () => { + await dashboard.loadSavedDashboard('links 001'); + await dashboard.switchToEditMode(); + + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await dashboardLinks.expectFlyoutIsOpen(); + + // Move the third link up one step + await dashboardLinks.reorderLinks('link003', 3, 1, true); + + await dashboardLinks.clickPanelEditorSaveButton(); + await header.waitUntilLoadingHasFinished(); + + // The second link in the component should be the link we moved + const listGroup = await testSubjects.find('links--component--listGroup'); + const listItem = await listGroup.findByCssSelector(`li:nth-child(2)`); + expect(await listItem.getVisibleText()).to.equal('links 003 - external'); + }); + + it('can edit link in existing panel', async () => { + await dashboard.loadSavedDashboard('links 001'); + await dashboard.switchToEditMode(); + + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await dashboardLinks.expectFlyoutIsOpen(); + + await dashboardLinks.editLinkByIndex(5); + await testSubjects.exists('links--linkEditor--flyout'); + await testSubjects.setValue('links--linkEditor--linkLabel--input', 'to be deleted'); + await dashboardLinks.clickLinksEditorSaveButton(); + await dashboardLinks.clickPanelEditorSaveButton(); + + await header.waitUntilLoadingHasFinished(); + const link = await testSubjects.find('dashboardLink--link005'); + expect(await link.getVisibleText()).to.equal('to be deleted'); + }); + + it('can delete link from existing panel', async () => { + await dashboard.loadSavedDashboard('links 001'); + await dashboard.switchToEditMode(); + + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await dashboardLinks.expectFlyoutIsOpen(); + + await dashboardLinks.deleteLinkByIndex(5); + await dashboardLinks.clickPanelEditorSaveButton(); + + await header.waitUntilLoadingHasFinished(); + expect(await dashboardLinks.getNumberOfLinksInPanel()).to.equal(4); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/links/links_navigation.ts b/test/functional/apps/dashboard_elements/links/links_navigation.ts new file mode 100644 index 0000000000000..7f525f6ffa870 --- /dev/null +++ b/test/functional/apps/dashboard_elements/links/links_navigation.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const filterBar = getService('filterBar'); + const testSubjects = getService('testSubjects'); + const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); + + const { dashboard, common, timePicker } = getPageObjects(['dashboard', 'common', 'timePicker']); + + const FROM_TIME = 'Oct 22, 2018 @ 00:00:00.000'; + const TO_TIME = 'Dec 3, 2018 @ 00:00:00.000'; + + describe('links panel navigation', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await security.testUser.setRoles([ + 'kibana_admin', + 'kibana_sample_admin', + 'test_logstash_reader', + ]); + await esArchiver.load('test/functional/fixtures/es_archiver/kibana_sample_data_flights'); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern' + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await common.setTime({ + from: FROM_TIME, + to: TO_TIME, + }); + }); + + after(async () => { + await kibanaServer.importExport.unload( + 'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern' + ); + await esArchiver.unload('test/functional/fixtures/es_archiver/kibana_sample_data_flights'); + await kibanaServer.uiSettings.unset('defaultIndex'); + await common.unsetTime(); + await security.testUser.restoreDefaults(); + }); + + describe('embeddable panel', () => { + afterEach(async () => { + await dashboard.clickDiscardChanges(); + }); + + it('adds links panel to top of dashboard', async () => { + await dashboard.loadSavedDashboard('links 003'); + await dashboard.switchToEditMode(); + await dashboardAddPanel.addEmbeddable('a few horizontal links', 'links'); + const topPanelTitle = (await dashboard.getPanelTitles())[0]; + expect(topPanelTitle).to.equal('a few horizontal links'); + }); + }); + + describe('dashboard links', () => { + afterEach(async () => { + // close any new tabs that were opened + const windowHandlers = await browser.getAllWindowHandles(); + if (windowHandlers.length > 1) { + await browser.closeCurrentWindow(); + await browser.switchToWindow(windowHandlers[0]); + } + }); + + it('should disable link if dashboard does not exist', async () => { + await dashboard.loadSavedDashboard('links 001'); + expect(await testSubjects.exists('dashboardLink--link004--error')).to.be(true); + expect(await testSubjects.isEnabled('dashboardLink--link004--error')).to.be(false); + }); + + it('useCurrentFilters should pass filter pills and query', async () => { + /** + * dashboard links002 has a saved filter and query bar. + * The link to dashboard links001 only has useCurrentFilters enabled + * so the link should pass the filters and query to dashboard links001 + * but should not override the date range. + */ + await dashboard.loadSavedDashboard('links 002'); + await testSubjects.click('dashboardLink--link001'); + expect(await dashboard.getDashboardIdFromCurrentUrl()).to.equal( + '0930f310-5bc2-11ee-9a85-7b86504227bc' + ); + // Should pass the filters + expect(await filterBar.getFilterCount()).to.equal(2); + const filterLabels = await filterBar.getFiltersLabel(); + expect( + filterLabels.includes('This filter should only pass from links002 to links001') + ).to.equal(true); + expect( + filterLabels.includes('This filter should not pass from links001 to links002') + ).to.equal(true); + + // Should not pass the date range + const time = await timePicker.getTimeConfig(); + expect(time.start).to.be('Oct 31, 2018 @ 00:00:00.000'); + expect(time.end).to.be('Nov 1, 2018 @ 00:00:00.000'); + + await dashboard.clickDiscardChanges(); + }); + + it('useCurrentDateRange should pass date range', async () => { + /** + * dashboard links001 has saved filters and a saved date range. + * dashboard links002 has a different saved date range than links001. + * The link to dashboard links002 only has useCurrentDateRange enabled + * so the link should override the date range on dashboard links002 + * but should not pass its filters. + */ + await dashboard.loadSavedDashboard('links 001'); + await testSubjects.click('dashboardLink--link002'); + expect(await dashboard.getDashboardIdFromCurrentUrl()).to.equal( + '24751520-5bc2-11ee-9a85-7b86504227bc' + ); + // Should pass the date range + const time = await timePicker.getTimeConfig(); + expect(time.start).to.be('Oct 31, 2018 @ 00:00:00.000'); + expect(time.end).to.be('Nov 1, 2018 @ 00:00:00.000'); + + // Should not pass the filters + expect(await filterBar.getFilterCount()).to.equal(1); + const filterLabels = await filterBar.getFiltersLabel(); + expect( + filterLabels.includes('This filter should only pass from links002 to links001') + ).to.equal(true); + expect( + filterLabels.includes('This filter should not pass from links001 to links002') + ).to.equal(false); + + await dashboard.clickDiscardChanges(); + }); + + it('openInNewTab should create an external link', async () => { + /** + * The link to dashboard links003 only has openInNewTab enabled. + * Clicking the link should open a new tab. + * Other dashboards should not pass their filters or date range + * to dashboard links003. + */ + await dashboard.loadSavedDashboard('links 001'); + await testSubjects.click('dashboardLink--link003'); + + // Should have opened another tab + const windowHandlers = await browser.getAllWindowHandles(); + expect(windowHandlers.length).to.equal(2); + await browser.switchToWindow(windowHandlers[1]); + expect(await dashboard.getDashboardIdFromCurrentUrl()).to.equal( + '27398c50-5bc2-11ee-9a85-7b86504227bc' + ); + + // Should not pass any filters + expect((await filterBar.getFiltersLabel()).length).to.equal(0); + + // Should not pass any date range + const time = await timePicker.getTimeConfig(); + expect(time.start).to.be('Dec 24, 2018 @ 00:00:00.000'); + expect(time.end).to.be('Dec 26, 2018 @ 00:00:00.000'); + }); + }); + + describe('external links', () => { + before(async () => { + await dashboard.loadSavedDashboard('dashboard with external links'); + }); + + afterEach(async () => { + // close any new tabs that were opened + const windowHandlers = await browser.getAllWindowHandles(); + if (windowHandlers.length > 1) { + await browser.closeCurrentWindow(); + await browser.switchToWindow(windowHandlers[0]); + } + }); + + it('should disable link if forbidden by external url policy', async () => { + const button = await testSubjects.find('externalLink--link777--error'); + const isDisabled = await button.getAttribute('disabled'); + expect(isDisabled).to.be('true'); + }); + + it('should create an external link when openInNewTab is enabled', async () => { + await testSubjects.click('externalLink--link999'); + + // Should have opened another tab + const windowHandlers = await browser.getAllWindowHandles(); + expect(windowHandlers.length).to.equal(2); + await browser.switchToWindow(windowHandlers[1]); + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.be('https://example.com/1'); + }); + + it('should open in same tab when openInNewTab is disabled', async () => { + await testSubjects.click('externalLink--link888'); + + // Should have opened in the same tab + const windowHandlers = await browser.getAllWindowHandles(); + expect(windowHandlers.length).to.equal(1); + await browser.switchToWindow(windowHandlers[0]); + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.be('https://example.com/2'); + }); + }); + }); +} diff --git a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json index 1af0e682d585c..500443f11900a 100644 --- a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json +++ b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json @@ -2288,7 +2288,7 @@ "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" }, "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", - "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", + "panelsJSON": "[{\"version\":\"7.3.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"3\"},\"panelIndex\":\"3\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_3\"}]", "refreshInterval": { "display": "Off", "pause": false, @@ -2743,4 +2743,435 @@ "dashboard": "8.6.0" }, "coreMigrationVersion": "8.6.0" -} \ No newline at end of file +} + +{ + "id": "16e12160-5bc2-11ee-9a85-7b86504227bc", + "type": "links", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-26T17:43:44.844Z", + "created_at": "2023-09-26T16:30:45.296Z", + "version": "WzIxMDEsMV0=", + "attributes": { + "links": [ + { + "label": "links 001 - filters", + "type": "dashboardLink", + "id": "link001", + "options": { + "openInNewTab": false, + "useCurrentDateRange": false, + "useCurrentFilters": true + }, + "order": 0, + "destinationRefName": "link_link001_dashboard" + }, + { + "label": "links 002 - date range", + "type": "dashboardLink", + "id": "link002", + "options": { + "openInNewTab": false, + "useCurrentDateRange": true, + "useCurrentFilters": false + }, + "order": 1, + "destinationRefName": "link_link002_dashboard" + }, + { + "label": "links 003 - external", + "type": "dashboardLink", + "id": "link003", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, + "order": 2, + "destinationRefName": "link_link003_dashboard" + } + ], + "layout": "vertical", + "title": "a few vertical links", + "description": "" + }, + "references": [ + { + "name": "link_link001_dashboard", + "type": "dashboard", + "id": "0930f310-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link002_dashboard", + "type": "dashboard", + "id": "24751520-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link003_dashboard", + "type": "dashboard", + "id": "27398c50-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0" +} + +{ + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c", + "type": "links", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-26T17:43:44.844Z", + "created_at": "2023-09-26T16:30:45.296Z", + "version": "WzIxMDEsMV0=", + "attributes": { + "links": [ + { + "label": "links 001 - filters", + "type": "dashboardLink", + "id": "link001", + "options": { + "openInNewTab": false, + "useCurrentDateRange": false, + "useCurrentFilters": true + }, + "order": 0, + "destinationRefName": "link_link001_dashboard" + }, + { + "label": "links 002 - date range", + "type": "dashboardLink", + "id": "link002", + "options": { + "openInNewTab": false, + "useCurrentDateRange": true, + "useCurrentFilters": false + }, + "order": 1, + "destinationRefName": "link_link002_dashboard" + }, + { + "label": "links 003 - external", + "type": "dashboardLink", + "id": "link003", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, + "order": 2, + "destinationRefName": "link_link003_dashboard" + }, + { + "label": "links 004 - broken", + "type": "dashboardLink", + "id": "link004", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, + "order": 3, + "destinationRefName": "link_link004_dashboard" + }, + { + "label": "links 005 - delete me", + "type": "dashboardLink", + "id": "link005", + "options": { + "openInNewTab": true, + "useCurrentDateRange": false, + "useCurrentFilters": false + }, + "order": 4, + "destinationRefName": "link_link005_dashboard" + } + ], + "layout": "horizontal", + "title": "a few horizontal links", + "description": "" + }, + "references": [ + { + "name": "link_link001_dashboard", + "type": "dashboard", + "id": "0930f310-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link002_dashboard", + "type": "dashboard", + "id": "24751520-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link003_dashboard", + "type": "dashboard", + "id": "27398c50-5bc2-11ee-9a85-7b86504227bc" + }, + { + "name": "link_link004_dashboard", + "type": "dashboard", + "id": "does-not-exist" + }, + { + "name": "link_link005_dashboard", + "type": "dashboard", + "id": "89566e10-5d4a-11ee-9513-d3f0b68b64f8" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0" +} + +{ + "id": "0930f310-5bc2-11ee-9a85-7b86504227bc", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-26T17:51:54.615Z", + "created_at": "2023-09-26T17:51:54.615Z", + "version": "WzIxMDksMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":false,\"alias\":\"This filter should not pass from links001 to links002\",\"key\":\"geo.dest\",\"field\":\"geo.dest\",\"params\":{\"query\":\"CA\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"geo.dest\":\"CA\"}},\"$state\":{\"store\":\"appState\"}}]}" + }, + "description": "", + "refreshInterval": { + "pause": true, + "value": 60000 + }, + "timeRestore": true, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":4,\"i\":\"26533acc-3516-445c-9c05-3b0e18686f38\"},\"panelIndex\":\"26533acc-3516-445c-9c05-3b0e18686f38\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_26533acc-3516-445c-9c05-3b0e18686f38\"}]", + "timeFrom": "2018-10-31T00:00:00.000Z", + "title": "links 001", + "timeTo": "2018-11-01T00:00:00.000Z" + }, + "references": [ + { + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern", + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c" + }, + { + "name": "26533acc-3516-445c-9c05-3b0e18686f38:panel_26533acc-3516-445c-9c05-3b0e18686f38", + "type": "links", + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c" + }, + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} + +{ + "id": "24751520-5bc2-11ee-9a85-7b86504227bc", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-26T17:52:24.244Z", + "created_at": "2023-09-26T17:52:24.244Z", + "version": "WzIxMTAsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"extension: \\\"links002 filter\\\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":false,\"alias\":\"This filter should only pass from links002 to links001\",\"key\":\"machine.os\",\"field\":\"machine.os\",\"params\":{\"query\":\"ios\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"machine.os\":\"ios\"}},\"$state\":{\"store\":\"appState\"}}]}" + }, + "description": "", + "refreshInterval": { + "pause": true, + "value": 60000 + }, + "timeRestore": true, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":4,\"i\":\"26533acc-3516-445c-9c05-3b0e18686f38\"},\"panelIndex\":\"26533acc-3516-445c-9c05-3b0e18686f38\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_26533acc-3516-445c-9c05-3b0e18686f38\"}]", + "timeFrom": "2018-11-11T00:00:00.000Z", + "title": "links 002", + "timeTo": "2018-11-12T00:00:00.000Z" + }, + "references": [ + { + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern", + "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c" + }, + { + "name": "26533acc-3516-445c-9c05-3b0e18686f38:panel_26533acc-3516-445c-9c05-3b0e18686f38", + "type": "links", + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c" + }, + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} + +{ + "id": "27398c50-5bc2-11ee-9a85-7b86504227bc", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-26T16:30:45.296Z", + "created_at": "2023-09-26T16:30:45.296Z", + "version": "WzIwOTEsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "description": "", + "timeRestore": true, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":3,\"i\":\"12e3dea4-44cc-405e-99de-408c5879ab55\"},\"panelIndex\":\"12e3dea4-44cc-405e-99de-408c5879ab55\",\"embeddableConfig\":{\"savedVis\":{\"id\":\"\",\"title\":\"\",\"description\":\"\",\"type\":\"markdown\",\"params\":{\"fontSize\":12,\"openLinksInNewTab\":false,\"markdown\":\"This panel appears at the top\"},\"uiState\":{},\"data\":{\"aggs\":[],\"searchSource\":{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}}},\"enhancements\":{}}}]", + "timeFrom": "2018-12-24T00:00:00.000Z", + "title": "links 003", + "timeTo": "2018-12-26T00:00:00.000Z" + }, + "references": [ + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} + +{ + "id": "89566e10-5d4a-11ee-9513-d3f0b68b64f8", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-27T15:29:41.343Z", + "created_at": "2023-09-27T15:29:41.343Z", + "version": "WzIwMDAsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "description": "", + "refreshInterval": { + "pause": true, + "value": 60000 + }, + "timeRestore": true, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":4,\"i\":\"7e4355a2-41e9-4627-982b-66ec7d98d58d\"},\"panelIndex\":\"7e4355a2-41e9-4627-982b-66ec7d98d58d\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_7e4355a2-41e9-4627-982b-66ec7d98d58d\"}]", + "timeFrom": "2018-12-24T00:00:00.000Z", + "title": "links 005", + "timeTo": "2018-12-26T00:00:00.000Z" + }, + "references": [ + { + "name": "7e4355a2-41e9-4627-982b-66ec7d98d58d:panel_7e4355a2-41e9-4627-982b-66ec7d98d58d", + "type": "links", + "id": "4dd6f084-ba56-4256-b018-b6df4092a66c" + }, + { + "type": "tag", + "id": "067be530-5bc2-11ee-9a85-7b86504227bc", + "name": "tag-ref-067be530-5bc2-11ee-9a85-7b86504227bc" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} + +{ + "id": "d8e17750-5d6c-11ee-be0d-9787f0515106", + "type": "links", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-27T19:34:17.157Z", + "created_at": "2023-09-27T19:34:17.157Z", + "version": "WzM0NDAsMV0=", + "attributes": { + "links": [ + { + "label": "opens in new tab", + "type": "externalLink", + "id": "link999", + "destination": "https://example.com/1", + "order": 0 + }, + { + "label": "opens in same tab", + "type": "externalLink", + "id": "link888", + "destination": "https://example.com/2", + "options": { + "openInNewTab": false, + "encodeUrl": true + }, + "order": 1 + }, + { + "label": "external link violation", + "type": "externalLink", + "id": "link777", + "destination": "https://danger.example.com", + "order": 2 + } + ], + "layout": "vertical", + "title": "some external links", + "description": "" + }, + "references": [], + "managed": false, + "coreMigrationVersion": "8.8.0" +} + +{ + "id": "379c1b60-5d6d-11ee-be0d-9787f0515106", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2023-09-27T19:36:56.086Z", + "created_at": "2023-09-27T19:36:56.086Z", + "version": "WzM0NDEsMV0=", + "attributes": { + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "description": "", + "timeRestore": false, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"links\",\"gridData\":{\"x\":0,\"y\":0,\"w\":9,\"h\":6,\"i\":\"fd19ed9e-8e8b-4769-bb25-0a76923e9f80\"},\"panelIndex\":\"fd19ed9e-8e8b-4769-bb25-0a76923e9f80\",\"embeddableConfig\":{\"disabledActions\":[\"OPEN_FLYOUT_ADD_DRILLDOWN\"],\"enhancements\":{}},\"panelRefName\":\"panel_fd19ed9e-8e8b-4769-bb25-0a76923e9f80\"}]", + "title": "dashboard with external links" + }, + "references": [ + { + "name": "fd19ed9e-8e8b-4769-bb25-0a76923e9f80:panel_fd19ed9e-8e8b-4769-bb25-0a76923e9f80", + "type": "links", + "id": "d8e17750-5d6c-11ee-be0d-9787f0515106" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "8.9.0" +} diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 6ff48c6b0cfbe..570edce8d96cb 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -612,8 +612,12 @@ export class DashboardPageObject extends FtrService { return visibilities; } + public async getPanels() { + return await this.find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes + } + public async getPanelDimensions() { - const panels = await this.find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes + const panels = await this.getPanels(); return await Promise.all( panels.map(async (panel) => { const size = await panel.getSize(); diff --git a/test/functional/page_objects/dashboard_page_links.ts b/test/functional/page_objects/dashboard_page_links.ts new file mode 100644 index 0000000000000..5055c16818c72 --- /dev/null +++ b/test/functional/page_objects/dashboard_page_links.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { LinksLayoutType } from '@kbn/links-plugin/common/content_management'; +import { FtrService } from '../ftr_provider_context'; + +export class DashboardPageLinks extends FtrService { + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly comboBox = this.ctx.getService('comboBox'); + + private readonly header = this.ctx.getPageObject('header'); + private readonly settings = this.ctx.getPageObject('settings'); + + public async toggleLinksLab(value?: boolean) { + await this.header.clickStackManagement(); + await this.settings.clickKibanaSettings(); + + await this.settings.toggleAdvancedSettingCheckbox('labs:dashboard:linksPanel', value); + } + + /* ----------------------------------------------------------- + Links panel + ----------------------------------------------------------- */ + + public async getAllLinksInPanel() { + const listGroup = await this.testSubjects.find('links--component--listGroup'); + return await listGroup.findAllByCssSelector('li'); + } + + public async getNumberOfLinksInPanel() { + const links = await this.getAllLinksInPanel(); + return links.length; + } + + /* ----------------------------------------------------------- + Links flyout + ----------------------------------------------------------- */ + + public async expectFlyoutIsOpen() { + await this.testSubjects.exists('links--panelEditor--flyout'); + } + + public async clickPanelEditorSaveButton() { + await this.expectFlyoutIsOpen(); + await this.testSubjects.clickWhenNotDisabled('links--panelEditor--saveBtn'); + } + + public async clickLinkEditorCloseButton() { + await this.testSubjects.click('links--linkEditor--closeBtn'); + } + + public async clickPanelEditorCloseButton() { + await this.testSubjects.click('links--panelEditor--closeBtn'); + } + + public async clickLinksEditorSaveButton() { + await this.testSubjects.clickWhenNotDisabled('links--linkEditor--saveBtn'); + } + + public async findDraggableLinkByIndex(index: number) { + await this.testSubjects.exists('links--panelEditor--flyout'); + const linksFormRow = await this.testSubjects.find('links--panelEditor--linksAreaDroppable'); + return await linksFormRow.findByCssSelector( + `[data-test-subj="links--panelEditor--draggableLink"]:nth-child(${index})` + ); + } + + public async addDashboardLink( + destination: string, + useCurrentFilters: boolean = true, + useCurrentDateRange: boolean = true, + openInNewTab: boolean = false, + linkLabel?: string + ) { + await this.expectFlyoutIsOpen(); + await this.testSubjects.click('links--panelEditor--addLinkBtn'); + await this.testSubjects.exists('links--linkEditor--flyout'); + const radioOption = await this.testSubjects.find('links--linkEditor--dashboardLink--radioBtn'); + const label = await radioOption.findByCssSelector('label[for="dashboardLink"]'); + await label.click(); + + await this.comboBox.set('links--linkEditor--dashboardLink--comboBox', destination); + if (linkLabel) { + await this.testSubjects.setValue('links--linkEditor--linkLabel--input', linkLabel); + } + + await this.testSubjects.setEuiSwitch( + 'dashboardDrillDownOptions--useCurrentFilters--checkbox', + useCurrentFilters ? 'check' : 'uncheck' + ); + await this.testSubjects.setEuiSwitch( + 'dashboardDrillDownOptions--useCurrentDateRange--checkbox', + useCurrentDateRange ? 'check' : 'uncheck' + ); + await this.testSubjects.setEuiSwitch( + 'dashboardDrillDownOptions--openInNewTab--checkbox', + openInNewTab ? 'check' : 'uncheck' + ); + + await this.clickLinksEditorSaveButton(); + } + + public async addExternalLink( + destination: string, + openInNewTab: boolean = true, + encodeUrl: boolean = true, + linkLabel?: string + ) { + await this.setExternalUrlInput(destination); + if (linkLabel) { + await this.testSubjects.setValue('links--linkEditor--linkLabel--input', linkLabel); + } + await this.testSubjects.setEuiSwitch( + 'urlDrilldownOpenInNewTab', + openInNewTab ? 'check' : 'uncheck' + ); + await this.testSubjects.setEuiSwitch('urlDrilldownEncodeUrl', encodeUrl ? 'check' : 'uncheck'); + + await this.clickLinksEditorSaveButton(); + } + + public async deleteLinkByIndex(index: number) { + const linkToDelete = await this.findDraggableLinkByIndex(index); + await this.retry.try(async () => { + await linkToDelete.moveMouseTo(); + await this.testSubjects.existOrFail(`panelEditorLink--deleteBtn`); + }); + const deleteButton = await linkToDelete.findByTestSubject(`panelEditorLink--deleteBtn`); + await deleteButton.click(); + } + + public async editLinkByIndex(index: number) { + const linkToEdit = await this.findDraggableLinkByIndex(index); + await this.retry.try(async () => { + await linkToEdit.moveMouseTo(); + await this.testSubjects.existOrFail(`panelEditorLink--editBtn`); + }); + const editButton = await linkToEdit.findByTestSubject(`panelEditorLink--editBtn`); + await editButton.click(); + } + + public async reorderLinks(linkLabel: string, startIndex: number, steps: number, reverse = false) { + const linkToMove = await this.findDraggableLinkByIndex(startIndex); + const draggableButton = await linkToMove.findByTestSubject(`panelEditorLink--dragHandle`); + expect(await draggableButton.getAttribute('data-rfd-drag-handle-draggable-id')).to.equal( + linkLabel + ); + await draggableButton.focus(); + await this.browser.pressKeys(this.browser.keys.SPACE); + + for (let i = 0; i < steps; i++) { + await this.browser.pressKeys(reverse ? this.browser.keys.UP : this.browser.keys.DOWN); + } + await this.browser.pressKeys(this.browser.keys.SPACE); + await this.retry.try(async () => { + expect(await linkToMove.elementHasClass('euiDraggable--isDragging')).to.be(false); + }); + } + + public async setLayout(layout: LinksLayoutType) { + await this.expectFlyoutIsOpen(); + const testSubj = `links--panelEditor--${layout}LayoutBtn`; + await this.testSubjects.click(testSubj); + } + + public async setExternalUrlInput(destination: string) { + await this.expectFlyoutIsOpen(); + await this.testSubjects.click('links--panelEditor--addLinkBtn'); + await this.testSubjects.exists('links--linkEditor--flyout'); + const option = await this.testSubjects.find('links--linkEditor--externalLink--radioBtn'); + const label = await option.findByCssSelector('label[for="externalLink"]'); + await label.click(); + await this.testSubjects.setValue('links--linkEditor--externalLink--input', destination); + } + + public async toggleSaveByReference(checked: boolean) { + await this.expectFlyoutIsOpen(); + await this.testSubjects.setEuiSwitch( + 'links--panelEditor--saveByReferenceSwitch', + checked ? 'check' : 'uncheck' + ); + } +} diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index 9a2312e0fedee..34859cfe943d3 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -31,6 +31,7 @@ import { SavedObjectsPageObject } from './management/saved_objects_page'; import { LegacyDataTableVisPageObject } from './legacy/data_table_vis'; import { IndexPatternFieldEditorPageObject } from './management/indexpattern_field_editor_page'; import { DashboardPageControls } from './dashboard_page_controls'; +import { DashboardPageLinks } from './dashboard_page_links'; import { UnifiedSearchPageObject } from './unified_search_page'; import { UnifiedFieldListPageObject } from './unified_field_list'; import { FilesManagementPageObject } from './files_management'; @@ -43,6 +44,7 @@ export const pageObjects = { context: ContextPageObject, dashboard: DashboardPageObject, dashboardControls: DashboardPageControls, + dashboardLinks: DashboardPageLinks, discover: DiscoverPageObject, error: ErrorPageObject, header: HeaderPageObject, diff --git a/test/functional/screenshots/baseline/dashboard_embed_mode_scrolling.png b/test/functional/screenshots/baseline/dashboard_embed_mode_scrolling.png index eed23bc7ed78f..a98a60b96ea68 100644 Binary files a/test/functional/screenshots/baseline/dashboard_embed_mode_scrolling.png and b/test/functional/screenshots/baseline/dashboard_embed_mode_scrolling.png differ diff --git a/test/tsconfig.json b/test/tsconfig.json index d71c7f9c8ffb1..fb20896356807 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -69,6 +69,7 @@ "@kbn/discover-plugin", "@kbn/core-http-common", "@kbn/event-annotation-plugin", - "@kbn/event-annotation-common" + "@kbn/event-annotation-common", + "@kbn/links-plugin" ] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 02071396e272f..2ea42b377596c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -934,6 +934,8 @@ "@kbn/license-management-plugin/*": ["x-pack/plugins/license_management/*"], "@kbn/licensing-plugin": ["x-pack/plugins/licensing"], "@kbn/licensing-plugin/*": ["x-pack/plugins/licensing/*"], + "@kbn/links-plugin": ["src/plugins/links"], + "@kbn/links-plugin/*": ["src/plugins/links/*"], "@kbn/lint-packages-cli": ["packages/kbn-lint-packages-cli"], "@kbn/lint-packages-cli/*": ["packages/kbn-lint-packages-cli/*"], "@kbn/lint-ts-projects-cli": ["packages/kbn-lint-ts-projects-cli"], diff --git a/x-pack/plugins/canvas/common/lib/embeddable_dataurl.ts b/x-pack/plugins/canvas/common/lib/embeddable_dataurl.ts index e76dedfe63b14..96e77a54e5398 100644 --- a/x-pack/plugins/canvas/common/lib/embeddable_dataurl.ts +++ b/x-pack/plugins/canvas/common/lib/embeddable_dataurl.ts @@ -5,9 +5,11 @@ * 2.0. */ +import { type ExplicitInputWithAttributes } from '@kbn/embeddable-plugin/public/lib'; import { EmbeddableInput } from '../../types'; -export const encode = (input: Partial) => - Buffer.from(JSON.stringify(input)).toString('base64'); +export const encode = ( + input: ExplicitInputWithAttributes | Partial | Readonly +) => Buffer.from(JSON.stringify(input)).toString('base64'); export const decode = (serializedInput: string) => JSON.parse(Buffer.from(serializedInput, 'base64').toString()); diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx index d93a9fb49d359..87a575ee44b12 100644 --- a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx +++ b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx @@ -61,7 +61,11 @@ export const AddEmbeddableFlyout: FC = ({ const embeddableFactories = getEmbeddableFactories(); const availableSavedObjects = Array.from(embeddableFactories) - .filter((factory) => isByValueEnabled || availableEmbeddables.includes(factory.type)) + .filter( + (factory) => + factory.type !== 'links' && // Links panels only exist on Dashboards + (isByValueEnabled || availableEmbeddables.includes(factory.type)) + ) .map((factory) => factory.savedObjectMetaData) .filter>(function ( maybeSavedObjectMetaData diff --git a/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx index 2258b63a49377..dbcd3b9cd2786 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx @@ -142,7 +142,7 @@ export const EditorMenu: FC = ({ addElement }) => { isEditable && !isContainerType && canCreateNew() && - !['visualization', 'ml'].some((factoryType) => { + !['visualization', 'ml', 'links'].some((factoryType) => { return type.includes(factoryType); }) ) diff --git a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/dashboard_drilldown_persistable_state.ts b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/dashboard_drilldown_persistable_state.ts index fe71ebd65ea8f..aff6c1cabedca 100644 --- a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/dashboard_drilldown_persistable_state.ts +++ b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/dashboard_drilldown_persistable_state.ts @@ -8,7 +8,7 @@ import { SavedObjectReference } from '@kbn/core/types'; import { PersistableStateService } from '@kbn/kibana-utils-plugin/common'; import { SerializedAction, SerializedEvent } from '@kbn/ui-actions-enhanced-plugin/common'; -import { DrilldownConfig } from './types'; +import { DashboardDrilldownConfig } from './types'; type DashboardDrilldownPersistableState = PersistableStateService; @@ -34,7 +34,7 @@ export const createInject = ({ drilldownId: string; }): DashboardDrilldownPersistableState['inject'] => { return (state: SerializedEvent, references: SavedObjectReference[]) => { - const action = state.action as SerializedAction; + const action = state.action as SerializedAction; const refName = generateRefName(state, drilldownId); const ref = references.find((r) => r.name === refName); if (!ref) return state; @@ -49,7 +49,7 @@ export const createExtract = ({ drilldownId: string; }): DashboardDrilldownPersistableState['extract'] => { return (state: SerializedEvent) => { - const action = state.action as SerializedAction; + const action = state.action as SerializedAction; const references: SavedObjectReference[] = action.config.dashboardId ? [ { diff --git a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/index.ts index fde3b5b06de2d..ab8d69d88475e 100644 --- a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/index.ts +++ b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/index.ts @@ -7,4 +7,4 @@ export { createExtract, createInject } from './dashboard_drilldown_persistable_state'; export { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN } from './constants'; -export type { DrilldownConfig } from './types'; +export type { DashboardDrilldownConfig } from './types'; diff --git a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/types.ts index ee68681ccc8f5..cf479aedcf8e5 100644 --- a/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/types.ts +++ b/x-pack/plugins/dashboard_enhanced/common/drilldowns/dashboard_drilldown/types.ts @@ -5,10 +5,8 @@ * 2.0. */ -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type DrilldownConfig = { +import { type DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public'; + +export type DashboardDrilldownConfig = { dashboardId?: string; - useCurrentFilters: boolean; - useCurrentDateRange: boolean; - openInNewTab: boolean; -}; +} & DashboardDrilldownOptions; diff --git a/x-pack/plugins/dashboard_enhanced/kibana.jsonc b/x-pack/plugins/dashboard_enhanced/kibana.jsonc index 30b70b7c9067f..88bb64bb00503 100644 --- a/x-pack/plugins/dashboard_enhanced/kibana.jsonc +++ b/x-pack/plugins/dashboard_enhanced/kibana.jsonc @@ -6,10 +6,7 @@ "id": "dashboardEnhanced", "server": true, "browser": true, - "configPath": [ - "xpack", - "dashboardEnhanced" - ], + "configPath": ["xpack", "dashboardEnhanced"], "requiredPlugins": [ "dashboard", "data", @@ -23,7 +20,8 @@ "embeddableEnhanced", "kibanaReact", "kibanaUtils", - "imageEmbeddable" + "imageEmbeddable", + "presentationUtil" ] } } diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx index 9305d515bdcef..a7d887e690f3a 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx @@ -15,11 +15,11 @@ import { UiActionsEnhancedDrilldownDefinition as Drilldown, } from '@kbn/ui-actions-enhanced-plugin/public'; import { CollectConfigProps, StartServicesGetter } from '@kbn/kibana-utils-plugin/public'; -import { DrilldownConfig } from '../../../../common/drilldowns/dashboard_drilldown/types'; +import { DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS } from '@kbn/presentation-util-plugin/public'; + import { CollectConfigContainer } from './components'; import { txtGoToDashboard } from './i18n'; import { Config } from './types'; - export interface Params { start: StartServicesGetter<{ uiActionsEnhanced: AdvancedUiActionsStart; @@ -56,15 +56,11 @@ export abstract class AbstractDashboardDrilldown >; - public readonly CollectConfig: React.FC< - CollectConfigProps - >; + public readonly CollectConfig: React.FC>; public readonly createConfig = () => ({ dashboardId: '', - useCurrentFilters: true, - useCurrentDateRange: true, - openInNewTab: false, + ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, }); public readonly isConfigValid = (config: Config): config is Config => { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx index 4a9818b3f23f5..0829bf1710719 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx @@ -80,11 +80,7 @@ export class CollectConfigContainer extends React.Component< return ( { @@ -94,24 +90,10 @@ export class CollectConfigContainer extends React.Component< } }} onSearchChange={this.debouncedLoadDashboards} - onCurrentFiltersToggle={() => - onConfig({ - ...config, - useCurrentFilters: !config.useCurrentFilters, - }) - } - onKeepRangeToggle={() => - onConfig({ - ...config, - useCurrentDateRange: !config.useCurrentDateRange, - }) - } - onOpenInNewTab={() => - onConfig({ - ...config, - openInNewTab: !config.openInNewTab, - }) - } + config={config} + onConfigChange={(changes: Partial) => { + onConfig({ ...config, ...changes }); + }} /> ); } diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx deleted file mode 100644 index 0c549f76b4ff4..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx +++ /dev/null @@ -1,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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { DashboardDrilldownConfig } from './dashboard_drilldown_config'; - -export const dashboards = [ - { value: 'dashboard1', label: 'Dashboard 1' }, - { value: 'dashboard2', label: 'Dashboard 2' }, - { value: 'dashboard3', label: 'Dashboard 3' }, -]; - -const InteractiveDemo: React.FC = () => { - const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1'); - const [currentFilters, setCurrentFilters] = React.useState(false); - const [keepRange, setKeepRange] = React.useState(false); - - return ( - setActiveDashboardId(id)} - onCurrentFiltersToggle={() => setCurrentFilters((old) => !old)} - onKeepRangeToggle={() => setKeepRange((old) => !old)} - onSearchChange={() => {}} - isLoading={false} - /> - ); -}; - -storiesOf( - 'services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config', - module -) - .add('default', () => ( - console.log('onDashboardSelect', e)} - onSearchChange={() => {}} - isLoading={false} - /> - )) - .add('with switches', () => ( - console.log('onDashboardSelect', e)} - onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')} - onKeepRangeToggle={() => console.log('onKeepRangeToggle')} - onSearchChange={() => {}} - isLoading={false} - /> - )) - .add('interactive demo', () => ); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx index d6e00f7de0edb..78ab1db7212b2 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx @@ -6,44 +6,37 @@ */ import React from 'react'; -import { EuiFormRow, EuiSwitch, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { - txtChooseDestinationDashboard, - txtUseCurrentFilters, - txtUseCurrentDateRange, - txtOpenInNewTab, -} from './i18n'; + withSuspense, + DashboardDrilldownOptionsComponent, +} from '@kbn/presentation-util-plugin/public'; + +import { txtChooseDestinationDashboard } from './i18n'; +import { Config as DrilldownConfig } from '../../types'; + +const DashboardDrilldownOptions = withSuspense(DashboardDrilldownOptionsComponent, null); export interface DashboardDrilldownConfigProps { - activeDashboardId?: string; dashboards: Array>; - currentFilters?: boolean; - keepRange?: boolean; - openInNewTab?: boolean; onDashboardSelect: (dashboardId: string) => void; - onCurrentFiltersToggle?: () => void; - onKeepRangeToggle?: () => void; - onOpenInNewTab?: () => void; onSearchChange: (searchString: string) => void; isLoading: boolean; error?: string; + config: DrilldownConfig; + onConfigChange: (changes: Partial) => void; } export const DashboardDrilldownConfig: React.FC = ({ - activeDashboardId, dashboards, - currentFilters, - keepRange, - openInNewTab, onDashboardSelect, - onCurrentFiltersToggle, - onKeepRangeToggle, - onOpenInNewTab, onSearchChange, isLoading, error, + config, + onConfigChange, }: DashboardDrilldownConfigProps) => { - const selectedTitle = dashboards.find((item) => item.value === activeDashboardId)?.label || ''; + const selectedTitle = dashboards.find((item) => item.value === config.dashboardId)?.label || ''; return ( <> @@ -51,7 +44,7 @@ export const DashboardDrilldownConfig: React.FC = async selectedOptions={ - activeDashboardId ? [{ label: selectedTitle, value: activeDashboardId }] : [] + config.dashboardId ? [{ label: selectedTitle, value: config.dashboardId }] : [] } options={dashboards} onChange={([{ value = '' } = { value: '' }]) => onDashboardSelect(value)} @@ -63,36 +56,7 @@ export const DashboardDrilldownConfig: React.FC = isInvalid={!!error} /> - {!!onCurrentFiltersToggle && ( - - - - )} - {!!onKeepRangeToggle && ( - - - - )} - {!!onOpenInNewTab && ( - - - - )} + ); }; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts index cf822a6a72d7a..5ee0794076348 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts @@ -13,24 +13,3 @@ export const txtChooseDestinationDashboard = i18n.translate( defaultMessage: 'Choose destination dashboard', } ); - -export const txtUseCurrentFilters = i18n.translate( - 'xpack.dashboard.components.DashboardDrilldownConfig.useCurrentFilters', - { - defaultMessage: 'Use filters and query from origin dashboard', - } -); - -export const txtUseCurrentDateRange = i18n.translate( - 'xpack.dashboard.components.DashboardDrilldownConfig.useCurrentDateRange', - { - defaultMessage: 'Use date range from origin dashboard', - } -); - -export const txtOpenInNewTab = i18n.translate( - 'xpack.dashboard.components.DashboardDrilldownConfig.openInNewTab', - { - defaultMessage: 'Open dashboard in new tab', - } -); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts index 599ecce6217e2..46181deabb8b5 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts @@ -6,8 +6,8 @@ */ import { UiActionsEnhancedBaseActionFactoryContext } from '@kbn/ui-actions-enhanced-plugin/public'; -import { DrilldownConfig } from '../../../../common'; +import { DashboardDrilldownConfig } from '../../../../common'; -export type Config = DrilldownConfig; +export type Config = DashboardDrilldownConfig; export type FactoryContext = UiActionsEnhancedBaseActionFactoryContext; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx index 468cbaf9940a9..9a984af52d21c 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx @@ -4,29 +4,25 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { type Filter, isFilterPinned, Query, TimeRange } from '@kbn/es-query'; +import { extractTimeRange, isFilterPinned } from '@kbn/es-query'; import type { KibanaLocation } from '@kbn/share-plugin/public'; -import { DashboardAppLocatorParams, cleanEmptyKeys } from '@kbn/dashboard-plugin/public'; +import { + cleanEmptyKeys, + DashboardAppLocatorParams, + getEmbeddableParams, +} from '@kbn/dashboard-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; -import { APPLY_FILTER_TRIGGER, isQuery, isTimeRange } from '@kbn/data-plugin/public'; -import { extractTimeRange } from '@kbn/es-query'; +import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public'; import { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public'; -import { IEmbeddable, EmbeddableInput } from '@kbn/embeddable-plugin/public'; import { EnhancedEmbeddableContext } from '@kbn/embeddable-enhanced-plugin/public'; import { IMAGE_CLICK_TRIGGER } from '@kbn/image-embeddable-plugin/public'; import { AbstractDashboardDrilldown, AbstractDashboardDrilldownParams, - AbstractDashboardDrilldownConfig as Config, } from '../abstract_dashboard_drilldown'; import { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN } from './constants'; import { createExtract, createInject } from '../../../../common'; - -interface EmbeddableQueryInput extends EmbeddableInput { - query?: Query; - filters?: Filter[]; - timeRange?: TimeRange; -} +import { AbstractDashboardDrilldownConfig as Config } from '../abstract_dashboard_drilldown'; type Context = EnhancedEmbeddableContext & ApplyGlobalFilterActionContext; export type Params = AbstractDashboardDrilldownParams; @@ -48,28 +44,16 @@ export class EmbeddableToDashboardDrilldown extends AbstractDashboardDrilldown { - const params: DashboardAppLocatorParams = { - dashboardId: config.dashboardId, - }; + let params: DashboardAppLocatorParams = { dashboardId: config.dashboardId }; if (context.embeddable) { - const embeddable = context.embeddable as IEmbeddable; - const input = embeddable.getInput(); - if (isQuery(input.query) && config.useCurrentFilters) params.query = input.query; - - // if useCurrentDashboardDataRange is enabled, then preserve current time range - // if undefined is passed, then destination dashboard will figure out time range itself - // for brush event this time range would be overwritten - if (isTimeRange(input.timeRange) && config.useCurrentDateRange) - params.timeRange = input.timeRange; - - // if useCurrentDashboardFilters enabled, then preserve all the filters (pinned, unpinned, and from controls) - // otherwise preserve only pinned - params.filters = config.useCurrentFilters - ? input.filters - : input.filters?.filter((f) => isFilterPinned(f)); + params = { + ...params, + ...getEmbeddableParams(context.embeddable, config), + }; } + /** Get event params */ const { restOfFilters: filtersFromEvent, timeRange: timeRangeFromEvent } = extractTimeRange( context.filters, context.timeFieldName diff --git a/x-pack/plugins/dashboard_enhanced/tsconfig.json b/x-pack/plugins/dashboard_enhanced/tsconfig.json index dd5a78b87dc4f..4c08a46b6e2d6 100644 --- a/x-pack/plugins/dashboard_enhanced/tsconfig.json +++ b/x-pack/plugins/dashboard_enhanced/tsconfig.json @@ -1,13 +1,9 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "outDir": "target/types", + "outDir": "target/types" }, - "include": [ - "common/**/*", - "public/**/*", - "server/**/*", - ], + "include": ["common/**/*", "public/**/*", "server/**/*"], "kbn_references": [ "@kbn/kibana-react-plugin", "@kbn/kibana-utils-plugin", @@ -22,9 +18,8 @@ "@kbn/es-query", "@kbn/unified-search-plugin", "@kbn/ui-actions-plugin", - "@kbn/image-embeddable-plugin" + "@kbn/image-embeddable-plugin", + "@kbn/presentation-util-plugin" ], - "exclude": [ - "target/**/*", - ] + "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts index 9cfeea206300e..500723a6786b9 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts @@ -107,6 +107,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -124,6 +125,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -143,6 +145,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.somethingFake}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -163,6 +166,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -191,6 +195,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -213,6 +218,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.invalid}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -235,6 +241,7 @@ describe('UrlDrilldown', () => { template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, }, openInNewTab: false, + encodeUrl: true, }; const context: ActionContext = { @@ -503,6 +510,7 @@ describe('encoding', () => { template: 'https://elastic.co?foo=head%26shoulders', }, openInNewTab: false, + encodeUrl: true, }; const url = await urlDrilldown.getHref(config, context); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index a9a7ce2caa70c..59dbc72ac62b0 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -39698,9 +39698,6 @@ "xpack.cloudLinks.setupGuide": "Guides de configuration", "xpack.cloudLinks.userMenuLinks.profileLinkText": "Modifier le profil", "xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "Choisir le tableau de bord de destination", - "xpack.dashboard.components.DashboardDrilldownConfig.openInNewTab": "Ouvrir le tableau de bord dans un nouvel onglet", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentDateRange": "Utiliser la plage de dates du tableau de bord d'origine", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentFilters": "Utiliser les filtres et la requête du tableau de bord d'origine", "xpack.dashboard.drilldown.errorDestinationDashboardIsMissing": "Le tableau de bord de destination (\"{dashboardId}\") n'existe plus. Choisissez un autre tableau de bord.", "xpack.dashboard.drilldown.goToDashboard": "Accéder au tableau de bord", "xpack.dashboard.FlyoutCreateDrilldownAction.displayName": "Créer une recherche", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 42eb7d822fdbd..8174504c2c605 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -39689,9 +39689,6 @@ "xpack.cloudLinks.setupGuide": "セットアップガイド", "xpack.cloudLinks.userMenuLinks.profileLinkText": "プロフィールを編集", "xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "対象ダッシュボードを選択", - "xpack.dashboard.components.DashboardDrilldownConfig.openInNewTab": "新しいタブでダッシュボードを開く", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentDateRange": "元のダッシュボードから日付範囲を使用", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentFilters": "元のダッシュボードからフィルターとクエリを使用", "xpack.dashboard.drilldown.errorDestinationDashboardIsMissing": "対象ダッシュボード('{dashboardId}')は存在しません。別のダッシュボードを選択してください。", "xpack.dashboard.drilldown.goToDashboard": "ダッシュボードに移動", "xpack.dashboard.FlyoutCreateDrilldownAction.displayName": "ドリルダウンを作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 61fcbaa2bbbd0..44791093b7bd5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -39683,9 +39683,6 @@ "xpack.cloudLinks.setupGuide": "设置指南", "xpack.cloudLinks.userMenuLinks.profileLinkText": "编辑配置文件", "xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard": "选择目标仪表板", - "xpack.dashboard.components.DashboardDrilldownConfig.openInNewTab": "在新选项卡中打开仪表板", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentDateRange": "使用源仪表板的日期范围", - "xpack.dashboard.components.DashboardDrilldownConfig.useCurrentFilters": "使用源仪表板的筛选和查询", "xpack.dashboard.drilldown.errorDestinationDashboardIsMissing": "目标仪表板(“{dashboardId}”)已不存在。选择其他仪表板。", "xpack.dashboard.drilldown.goToDashboard": "前往仪表板", "xpack.dashboard.FlyoutCreateDrilldownAction.displayName": "创建向下钻取", diff --git a/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson b/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson index 87cb044789f7e..f68023c5d4538 100644 --- a/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson +++ b/x-pack/test_serverless/functional/test_suites/search/dashboards/exports/serverless_dashboard_8_11.ndjson @@ -1,5 +1,5 @@ -{"attributes":{"fields":"[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]","timeFieldName":"@timestamp","title":"logstash-*"},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T14:45:18.313Z","id":"logstash-*","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"7.11.0","updated_at":"2023-08-25T14:45:18.313Z","version":"WzE0MSwxXQ=="} -{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"4ba1a1be-6e67-434b-b3a0-f30db8ea5395":{"columnOrder":["7a5d833b-ca6f-4e48-a924-d2a28d365dc3","3cf18f28-3495-4d45-a55f-d97f88022099","3dc0bd55-2087-4e60-aea2-f9910714f7db"],"columns":{"3cf18f28-3495-4d45-a55f-d97f88022099":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"includeEmptyRows":false,"interval":"auto"},"scale":"interval","sourceField":"@timestamp"},"3dc0bd55-2087-4e60-aea2-f9910714f7db":{"dataType":"number","isBucketed":false,"label":"Average of bytes","operationType":"average","scale":"ratio","sourceField":"bytes"},"7a5d833b-ca6f-4e48-a924-d2a28d365dc3":{"dataType":"ip","isBucketed":true,"label":"Top values of ip","operationType":"terms","params":{"orderBy":{"columnId":"3dc0bd55-2087-4e60-aea2-f9910714f7db","type":"column"},"orderDirection":"desc","size":3},"scale":"ordinal","sourceField":"ip"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"layers":[{"accessors":["3dc0bd55-2087-4e60-aea2-f9910714f7db"],"layerId":"4ba1a1be-6e67-434b-b3a0-f30db8ea5395","seriesType":"bar_stacked","splitAccessor":"7a5d833b-ca6f-4e48-a924-d2a28d365dc3","xAccessor":"3cf18f28-3495-4d45-a55f-d97f88022099"}],"legend":{"isVisible":true,"legendSize":"auto","position":"right"},"preferredSeriesType":"bar_stacked"}},"title":"lnsXYvis","visualizationType":"lnsXY"},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T14:45:18.313Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-4ba1a1be-6e67-434b-b3a0-f30db8ea5395","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-08-25T14:45:18.313Z","version":"WzE0MywxXQ=="} -{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"c61a8afb-a185-4fae-a064-fb3846f6c451":{"columnOrder":["2cd09808-3915-49f4-b3b0-82767eba23f7"],"columns":{"2cd09808-3915-49f4-b3b0-82767eba23f7":{"dataType":"number","isBucketed":false,"label":"Maximum of bytes","operationType":"max","scale":"ratio","sourceField":"bytes"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"accessor":"2cd09808-3915-49f4-b3b0-82767eba23f7","isHorizontal":false,"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","layers":[{"accessors":["d3e62a7a-c259-4fff-a2fc-eebf20b7008a","26ef70a9-c837-444c-886e-6bd905ee7335"],"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","seriesType":"area","splitAccessor":"54cd64ed-2a44-4591-af84-b2624504569a","xAccessor":"d6e40cea-6299-43b4-9c9d-b4ee305a2ce8"}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"area","size":"xl","textAlign":"center","titlePosition":"bottom"}},"title":"Artistpreviouslyknownaslens","visualizationType":"lnsLegacyMetric"},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T14:45:18.313Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-c61a8afb-a185-4fae-a064-fb3846f6c451","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-08-25T14:45:18.313Z","version":"WzE0MiwxXQ=="} -{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":true,\"alias\":null,\"key\":\"agent.raw\",\"field\":\"agent.raw\",\"params\":{\"query\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"agent.raw\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"}},\"$state\":{\"store\":\"appState\"}}]}"},"optionsJSON":"{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}","panelsJSON":"[{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":12,\"h\":13,\"i\":\"5b087cde-634a-4815-9093-71891a900380\"},\"panelIndex\":\"5b087cde-634a-4815-9093-71891a900380\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65\"}],\"state\":{\"visualization\":{\"shape\":\"donut\",\"layers\":[{\"layerId\":\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\",\"primaryGroups\":[\"fbf03774-001d-4032-a808-004140e94918\"],\"metrics\":[\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"default\",\"nestedLegend\":false,\"layerType\":\"data\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\":{\"columns\":{\"fbf03774-001d-4032-a808-004140e94918\":{\"label\":\"Top 3 values of ip\",\"dataType\":\"ip\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"ip\",\"isBucketed\":true,\"params\":{\"size\":3,\"orderBy\":{\"type\":\"column\",\"columnId\":\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false}},\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\":{\"label\":\"Average of bytes\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"bytes\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"fbf03774-001d-4032-a808-004140e94918\",\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{},\"hidePanelTitles\":false},\"title\":\"Custom Title\"},{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":12,\"y\":1,\"w\":36,\"h\":13,\"i\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\"},\"panelIndex\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"description\":\"Wow what a neat description\",\"enhancements\":{}},\"title\":\"Custom Title on a Library Item\",\"panelRefName\":\"panel_ee9dceec-afc3-4258-9998-44a00c2b36fc\"},{\"version\":\"8.11.0\",\"type\":\"map\",\"gridData\":{\"x\":0,\"y\":14,\"w\":38,\"h\":16,\"i\":\"7557df66-cfde-4401-a926-aff27d774715\"},\"panelIndex\":\"7557df66-cfde-4401-a926-aff27d774715\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"layerListJSON\":\"[{\\\"locale\\\":\\\"autoselect\\\",\\\"sourceDescriptor\\\":{\\\"type\\\":\\\"EMS_TMS\\\",\\\"isAutoSelect\\\":true,\\\"lightModeDefault\\\":\\\"road_map_desaturated\\\"},\\\"id\\\":\\\"710998eb-fda1-462c-80f2-d498df132ebf\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"EMS_VECTOR_TILE\\\",\\\"color\\\":\\\"\\\"},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"EMS_VECTOR_TILE\\\"},{\\\"sourceDescriptor\\\":{\\\"geoField\\\":\\\"geo.coordinates\\\",\\\"scalingType\\\":\\\"MVT\\\",\\\"id\\\":\\\"4ed5225d-d42d-40e3-9515-5a918208db27\\\",\\\"type\\\":\\\"ES_SEARCH\\\",\\\"applyGlobalQuery\\\":true,\\\"applyGlobalTime\\\":true,\\\"applyForceRefresh\\\":true,\\\"filterByMapBounds\\\":true,\\\"tooltipProperties\\\":[],\\\"sortField\\\":\\\"\\\",\\\"sortOrder\\\":\\\"desc\\\",\\\"topHitsGroupByTimeseries\\\":false,\\\"topHitsSplitField\\\":\\\"\\\",\\\"topHitsSize\\\":1,\\\"indexPatternRefName\\\":\\\"layer_1_source_index_pattern\\\"},\\\"id\\\":\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"VECTOR\\\",\\\"properties\\\":{\\\"icon\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"marker\\\"}},\\\"fillColor\\\":{\\\"type\\\":\\\"DYNAMIC\\\",\\\"options\\\":{\\\"color\\\":\\\"Blues\\\",\\\"colorCategory\\\":\\\"palette_0\\\",\\\"field\\\":{\\\"name\\\":\\\"extension.raw\\\",\\\"origin\\\":\\\"source\\\"},\\\"fieldMetaOptions\\\":{\\\"isEnabled\\\":true,\\\"sigma\\\":3},\\\"type\\\":\\\"CATEGORICAL\\\",\\\"otherCategoryColor\\\":\\\"#000000\\\"}},\\\"lineColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#41937c\\\"}},\\\"lineWidth\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":0}},\\\"iconSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":6}},\\\"iconOrientation\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"orientation\\\":0}},\\\"labelText\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"\\\"}},\\\"labelColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#000000\\\"}},\\\"labelSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":14}},\\\"labelZoomRange\\\":{\\\"options\\\":{\\\"useLayerZoomRange\\\":true,\\\"minZoom\\\":0,\\\"maxZoom\\\":24}},\\\"labelBorderColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#FFFFFF\\\"}},\\\"symbolizeAs\\\":{\\\"options\\\":{\\\"value\\\":\\\"circle\\\"}},\\\"labelBorderSize\\\":{\\\"options\\\":{\\\"size\\\":\\\"SMALL\\\"}},\\\"labelPosition\\\":{\\\"options\\\":{\\\"position\\\":\\\"CENTER\\\"}}},\\\"isTimeAware\\\":true},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"MVT_VECTOR\\\",\\\"joins\\\":[],\\\"disableTooltips\\\":false}]\",\"mapStateJSON\":\"{\\\"adHocDataViews\\\":[],\\\"zoom\\\":3.53,\\\"center\\\":{\\\"lon\\\":-98.19524,\\\"lat\\\":42.06188},\\\"timeFilters\\\":{\\\"from\\\":\\\"2015-09-19T06:31:44.000Z\\\",\\\"to\\\":\\\"2015-09-23T18:31:44.000Z\\\"},\\\"refreshConfig\\\":{\\\"isPaused\\\":true,\\\"interval\\\":60000},\\\"query\\\":{\\\"query\\\":\\\"\\\",\\\"language\\\":\\\"kuery\\\"},\\\"filters\\\":[],\\\"settings\\\":{\\\"autoFitToDataBounds\\\":false,\\\"backgroundColor\\\":\\\"#ffffff\\\",\\\"customIcons\\\":[],\\\"disableInteractive\\\":false,\\\"disableTooltipControl\\\":false,\\\"hideToolbarOverlay\\\":false,\\\"hideLayerControl\\\":false,\\\"hideViewControl\\\":false,\\\"initialLocation\\\":\\\"LAST_SAVED_LOCATION\\\",\\\"fixedLocation\\\":{\\\"lat\\\":0,\\\"lon\\\":0,\\\"zoom\\\":2},\\\"browserLocation\\\":{\\\"zoom\\\":2},\\\"keydownScrollZoom\\\":false,\\\"maxZoom\\\":24,\\\"minZoom\\\":0,\\\"showScaleControl\\\":false,\\\"showSpatialFilters\\\":true,\\\"showTimesliderToggleButton\\\":true,\\\"spatialFiltersAlpa\\\":0.3,\\\"spatialFiltersFillColor\\\":\\\"#DA8B45\\\",\\\"spatialFiltersLineColor\\\":\\\"#DA8B45\\\"}}\",\"uiStateJSON\":\"{\\\"isLayerTOCOpen\\\":true,\\\"openTOCDetails\\\":[\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\"]}\"},\"mapCenter\":{\"lat\":42.37743,\"lon\":-101.55858,\"zoom\":3.53},\"mapBuffer\":{\"minLon\":-135,\"minLat\":21.94305,\"maxLon\":-45,\"maxLat\":55.77657},\"isLayerTOCOpen\":true,\"openTOCDetails\":[\"07facc2c-117d-4335-bd53-90e0ab36aa53\"],\"hiddenLayers\":[],\"enhancements\":{}}},{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":20,\"w\":10,\"h\":9,\"i\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"},\"panelIndex\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"timeRange\":{\"from\":\"2015-09-21T06:31:44.000Z\",\"to\":\"2015-09-23T18:31:44.000Z\"},\"enhancements\":{}},\"panelRefName\":\"panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"},{\"version\":\"8.11.0\",\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":12,\"w\":10,\"h\":7,\"i\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\"},\"panelIndex\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\"},{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb\"}],\"state\":{\"visualization\":{\"layerId\":\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\",\"layerType\":\"data\",\"metricAccessor\":\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\",\"showBar\":false,\"trendlineLayerId\":\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\",\"trendlineLayerType\":\"metricTrendline\",\"trendlineTimeAccessor\":\"c993571d-2a82-4162-a367-3542041c811f\",\"trendlineMetricAccessor\":\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\",\"color\":\"#fccada\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\":{\"columns\":{\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}},\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\":{\"linkToLayers\":[\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\"],\"columns\":{\"c993571d-2a82-4162-a367-3542041c811f\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"c993571d-2a82-4162-a367-3542041c811f\",\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{}}}]","refreshInterval":{"pause":true,"value":60000},"timeFrom":"2015-09-19T06:31:44.000Z","timeRestore":true,"timeTo":"2015-09-23T18:31:44.000Z","title":"Super Saved Serverless","version":1},"coreMigrationVersion":"8.8.0","created_at":"2023-08-25T16:37:35.012Z","id":"4dc11f80-42b5-11ee-89b3-c776e03685a8","managed":false,"references":[{"id":"logstash-*","name":"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index","type":"index-pattern"},{"id":"logstash-*","name":"5b087cde-634a-4815-9093-71891a900380:indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","name":"ee9dceec-afc3-4258-9998-44a00c2b36fc:panel_ee9dceec-afc3-4258-9998-44a00c2b36fc","type":"lens"},{"id":"logstash-*","name":"7557df66-cfde-4401-a926-aff27d774715:layer_1_source_index_pattern","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","name":"d3089be5-dff0-4bbe-9a36-76dd1dec98ef:panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef","type":"lens"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f","type":"index-pattern"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb","type":"index-pattern"}],"type":"dashboard","typeMigrationVersion":"8.9.0","updated_at":"2023-08-25T16:37:35.012Z","version":"WzE2NCwxXQ=="} +{"attributes":{"fields":"[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]","timeFieldName":"@timestamp","title":"logstash-*"},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:19.398Z","id":"logstash-*","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"7.11.0","updated_at":"2023-09-28T17:07:19.398Z","version":"WzE4LDFd"} +{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"4ba1a1be-6e67-434b-b3a0-f30db8ea5395":{"columnOrder":["7a5d833b-ca6f-4e48-a924-d2a28d365dc3","3cf18f28-3495-4d45-a55f-d97f88022099","3dc0bd55-2087-4e60-aea2-f9910714f7db"],"columns":{"3cf18f28-3495-4d45-a55f-d97f88022099":{"dataType":"date","isBucketed":true,"label":"@timestamp","operationType":"date_histogram","params":{"includeEmptyRows":false,"interval":"auto"},"scale":"interval","sourceField":"@timestamp"},"3dc0bd55-2087-4e60-aea2-f9910714f7db":{"dataType":"number","isBucketed":false,"label":"Average of bytes","operationType":"average","scale":"ratio","sourceField":"bytes"},"7a5d833b-ca6f-4e48-a924-d2a28d365dc3":{"dataType":"ip","isBucketed":true,"label":"Top values of ip","operationType":"terms","params":{"orderBy":{"columnId":"3dc0bd55-2087-4e60-aea2-f9910714f7db","type":"column"},"orderDirection":"desc","size":3},"scale":"ordinal","sourceField":"ip"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"layers":[{"accessors":["3dc0bd55-2087-4e60-aea2-f9910714f7db"],"layerId":"4ba1a1be-6e67-434b-b3a0-f30db8ea5395","seriesType":"bar_stacked","splitAccessor":"7a5d833b-ca6f-4e48-a924-d2a28d365dc3","xAccessor":"3cf18f28-3495-4d45-a55f-d97f88022099"}],"legend":{"isVisible":true,"legendSize":"auto","position":"right"},"preferredSeriesType":"bar_stacked"}},"title":"lnsXYvis","visualizationType":"lnsXY"},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:19.398Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-4ba1a1be-6e67-434b-b3a0-f30db8ea5395","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-09-28T17:07:19.398Z","version":"WzE5LDFd"} +{"attributes":{"state":{"datasourceStates":{"formBased":{"layers":{"c61a8afb-a185-4fae-a064-fb3846f6c451":{"columnOrder":["2cd09808-3915-49f4-b3b0-82767eba23f7"],"columns":{"2cd09808-3915-49f4-b3b0-82767eba23f7":{"dataType":"number","isBucketed":false,"label":"Maximum of bytes","operationType":"max","scale":"ratio","sourceField":"bytes"}}}}}},"filters":[],"query":{"language":"kuery","query":""},"visualization":{"accessor":"2cd09808-3915-49f4-b3b0-82767eba23f7","isHorizontal":false,"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","layers":[{"accessors":["d3e62a7a-c259-4fff-a2fc-eebf20b7008a","26ef70a9-c837-444c-886e-6bd905ee7335"],"layerId":"c61a8afb-a185-4fae-a064-fb3846f6c451","layerType":"data","seriesType":"area","splitAccessor":"54cd64ed-2a44-4591-af84-b2624504569a","xAccessor":"d6e40cea-6299-43b4-9c9d-b4ee305a2ce8"}],"legend":{"isVisible":true,"position":"right"},"preferredSeriesType":"area","size":"xl","textAlign":"center","titlePosition":"bottom"}},"title":"Artistpreviouslyknownaslens","visualizationType":"lnsLegacyMetric"},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:19.398Z","id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","managed":false,"references":[{"id":"logstash-*","name":"indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"logstash-*","name":"indexpattern-datasource-layer-c61a8afb-a185-4fae-a064-fb3846f6c451","type":"index-pattern"}],"type":"lens","typeMigrationVersion":"8.9.0","updated_at":"2023-09-28T17:07:19.398Z","version":"WzIwLDFd"} +{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[{\"meta\":{\"disabled\":false,\"negate\":true,\"alias\":null,\"key\":\"agent.raw\",\"field\":\"agent.raw\",\"params\":{\"query\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"},\"type\":\"phrase\",\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match_phrase\":{\"agent.raw\":\"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\"}},\"$state\":{\"store\":\"appState\"}}]}"},"optionsJSON":"{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}","panelsJSON":"[{\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":12,\"h\":13,\"i\":\"5b087cde-634a-4815-9093-71891a900380\"},\"panelIndex\":\"5b087cde-634a-4815-9093-71891a900380\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"shape\":\"donut\",\"layers\":[{\"layerId\":\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\",\"primaryGroups\":[\"fbf03774-001d-4032-a808-004140e94918\"],\"metrics\":[\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"default\",\"nestedLegend\":false,\"layerType\":\"data\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"36023ac0-43e8-41de-aee2-ebabf1b4fb65\":{\"columns\":{\"fbf03774-001d-4032-a808-004140e94918\":{\"label\":\"Top 3 values of ip\",\"dataType\":\"ip\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"ip\",\"isBucketed\":true,\"params\":{\"size\":3,\"orderBy\":{\"type\":\"column\",\"columnId\":\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false}},\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\":{\"label\":\"Average of bytes\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"bytes\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"fbf03774-001d-4032-a808-004140e94918\",\"65a386b0-e74e-4076-a419-ee1d3ed7ce87\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{},\"hidePanelTitles\":false},\"title\":\"Custom Title\"},{\"type\":\"lens\",\"gridData\":{\"x\":12,\"y\":0,\"w\":36,\"h\":13,\"i\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\"},\"panelIndex\":\"ee9dceec-afc3-4258-9998-44a00c2b36fc\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"description\":\"Wow what a neat description\",\"enhancements\":{}},\"title\":\"Custom Title on a Library Item\",\"panelRefName\":\"panel_ee9dceec-afc3-4258-9998-44a00c2b36fc\"},{\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":13,\"w\":10,\"h\":7,\"i\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\"},\"panelIndex\":\"b7b17dfe-87b7-4c8b-81f5-aab625251c3f\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsMetric\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\",\"type\":\"index-pattern\"},{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"layerId\":\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\",\"layerType\":\"data\",\"metricAccessor\":\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\",\"showBar\":false,\"trendlineLayerId\":\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\",\"trendlineLayerType\":\"metricTrendline\",\"trendlineTimeAccessor\":\"c993571d-2a82-4162-a367-3542041c811f\",\"trendlineMetricAccessor\":\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\",\"color\":\"#fccada\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\":{\"columns\":{\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"f8cf87ac-8f5b-4eda-b266-0c37d0205858\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}},\"5dd18300-eb6a-4259-8e68-2e302c2b9bcb\":{\"linkToLayers\":[\"c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f\"],\"columns\":{\"c993571d-2a82-4162-a367-3542041c811f\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\":{\"label\":\"Median RAM\",\"dataType\":\"number\",\"operationType\":\"median\",\"sourceField\":\"machine.ram\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"bytes\",\"params\":{\"decimals\":1}}},\"customLabel\":true}},\"columnOrder\":[\"c993571d-2a82-4162-a367-3542041c811f\",\"5c57e520-1e7b-41f4-a362-a20f8b2762d4\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{}}},{\"type\":\"map\",\"gridData\":{\"x\":0,\"y\":13,\"w\":38,\"h\":16,\"i\":\"7557df66-cfde-4401-a926-aff27d774715\"},\"panelIndex\":\"7557df66-cfde-4401-a926-aff27d774715\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"layerListJSON\":\"[{\\\"locale\\\":\\\"autoselect\\\",\\\"sourceDescriptor\\\":{\\\"type\\\":\\\"EMS_TMS\\\",\\\"isAutoSelect\\\":true,\\\"lightModeDefault\\\":\\\"road_map_desaturated\\\"},\\\"id\\\":\\\"710998eb-fda1-462c-80f2-d498df132ebf\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"EMS_VECTOR_TILE\\\",\\\"color\\\":\\\"\\\"},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"EMS_VECTOR_TILE\\\"},{\\\"sourceDescriptor\\\":{\\\"geoField\\\":\\\"geo.coordinates\\\",\\\"scalingType\\\":\\\"MVT\\\",\\\"id\\\":\\\"4ed5225d-d42d-40e3-9515-5a918208db27\\\",\\\"type\\\":\\\"ES_SEARCH\\\",\\\"applyGlobalQuery\\\":true,\\\"applyGlobalTime\\\":true,\\\"applyForceRefresh\\\":true,\\\"filterByMapBounds\\\":true,\\\"tooltipProperties\\\":[],\\\"sortField\\\":\\\"\\\",\\\"sortOrder\\\":\\\"desc\\\",\\\"topHitsGroupByTimeseries\\\":false,\\\"topHitsSplitField\\\":\\\"\\\",\\\"topHitsSize\\\":1,\\\"indexPatternRefName\\\":\\\"layer_1_source_index_pattern\\\"},\\\"id\\\":\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\",\\\"label\\\":null,\\\"minZoom\\\":0,\\\"maxZoom\\\":24,\\\"alpha\\\":1,\\\"visible\\\":true,\\\"style\\\":{\\\"type\\\":\\\"VECTOR\\\",\\\"properties\\\":{\\\"icon\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"marker\\\"}},\\\"fillColor\\\":{\\\"type\\\":\\\"DYNAMIC\\\",\\\"options\\\":{\\\"color\\\":\\\"Blues\\\",\\\"colorCategory\\\":\\\"palette_0\\\",\\\"field\\\":{\\\"name\\\":\\\"extension.raw\\\",\\\"origin\\\":\\\"source\\\"},\\\"fieldMetaOptions\\\":{\\\"isEnabled\\\":true,\\\"sigma\\\":3},\\\"type\\\":\\\"CATEGORICAL\\\",\\\"otherCategoryColor\\\":\\\"#000000\\\"}},\\\"lineColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#41937c\\\"}},\\\"lineWidth\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":0}},\\\"iconSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":6}},\\\"iconOrientation\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"orientation\\\":0}},\\\"labelText\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"value\\\":\\\"\\\"}},\\\"labelColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#000000\\\"}},\\\"labelSize\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"size\\\":14}},\\\"labelZoomRange\\\":{\\\"options\\\":{\\\"useLayerZoomRange\\\":true,\\\"minZoom\\\":0,\\\"maxZoom\\\":24}},\\\"labelBorderColor\\\":{\\\"type\\\":\\\"STATIC\\\",\\\"options\\\":{\\\"color\\\":\\\"#FFFFFF\\\"}},\\\"symbolizeAs\\\":{\\\"options\\\":{\\\"value\\\":\\\"circle\\\"}},\\\"labelBorderSize\\\":{\\\"options\\\":{\\\"size\\\":\\\"SMALL\\\"}},\\\"labelPosition\\\":{\\\"options\\\":{\\\"position\\\":\\\"CENTER\\\"}}},\\\"isTimeAware\\\":true},\\\"includeInFitToBounds\\\":true,\\\"type\\\":\\\"MVT_VECTOR\\\",\\\"joins\\\":[],\\\"disableTooltips\\\":false}]\",\"mapStateJSON\":\"{\\\"adHocDataViews\\\":[],\\\"zoom\\\":3.53,\\\"center\\\":{\\\"lon\\\":-98.19524,\\\"lat\\\":42.06188},\\\"timeFilters\\\":{\\\"from\\\":\\\"2015-09-19T06:31:44.000Z\\\",\\\"to\\\":\\\"2015-09-23T18:31:44.000Z\\\"},\\\"refreshConfig\\\":{\\\"isPaused\\\":true,\\\"interval\\\":60000},\\\"query\\\":{\\\"query\\\":\\\"\\\",\\\"language\\\":\\\"kuery\\\"},\\\"filters\\\":[],\\\"settings\\\":{\\\"autoFitToDataBounds\\\":false,\\\"backgroundColor\\\":\\\"#ffffff\\\",\\\"customIcons\\\":[],\\\"disableInteractive\\\":false,\\\"disableTooltipControl\\\":false,\\\"hideToolbarOverlay\\\":false,\\\"hideLayerControl\\\":false,\\\"hideViewControl\\\":false,\\\"initialLocation\\\":\\\"LAST_SAVED_LOCATION\\\",\\\"fixedLocation\\\":{\\\"lat\\\":0,\\\"lon\\\":0,\\\"zoom\\\":2},\\\"browserLocation\\\":{\\\"zoom\\\":2},\\\"keydownScrollZoom\\\":false,\\\"maxZoom\\\":24,\\\"minZoom\\\":0,\\\"showScaleControl\\\":false,\\\"showSpatialFilters\\\":true,\\\"showTimesliderToggleButton\\\":true,\\\"spatialFiltersAlpa\\\":0.3,\\\"spatialFiltersFillColor\\\":\\\"#DA8B45\\\",\\\"spatialFiltersLineColor\\\":\\\"#DA8B45\\\"}}\",\"uiStateJSON\":\"{\\\"isLayerTOCOpen\\\":true,\\\"openTOCDetails\\\":[\\\"07facc2c-117d-4335-bd53-90e0ab36aa53\\\"]}\"},\"mapCenter\":{\"lat\":42.37743,\"lon\":-101.55858,\"zoom\":3.53},\"mapBuffer\":{\"minLon\":-135,\"minLat\":21.94305,\"maxLon\":-45,\"maxLat\":55.77657},\"isLayerTOCOpen\":true,\"openTOCDetails\":[\"07facc2c-117d-4335-bd53-90e0ab36aa53\"],\"hiddenLayers\":[],\"enhancements\":{}}},{\"type\":\"lens\",\"gridData\":{\"x\":38,\"y\":20,\"w\":10,\"h\":9,\"i\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"},\"panelIndex\":\"d3089be5-dff0-4bbe-9a36-76dd1dec98ef\",\"embeddableConfig\":{\"hidePanelTitles\":false,\"timeRange\":{\"from\":\"2015-09-21T06:31:44.000Z\",\"to\":\"2015-09-23T18:31:44.000Z\"},\"enhancements\":{}},\"panelRefName\":\"panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef\"}]","refreshInterval":{"pause":true,"value":60000},"timeFrom":"2015-09-19T06:31:44.000Z","timeRestore":true,"timeTo":"2015-09-23T18:31:44.000Z","title":"Super Saved Serverless","version":1},"coreMigrationVersion":"8.8.0","created_at":"2023-09-28T17:07:48.232Z","id":"4dc11f80-42b5-11ee-89b3-c776e03685a8","managed":false,"references":[{"id":"logstash-*","name":"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index","type":"index-pattern"},{"id":"logstash-*","name":"5b087cde-634a-4815-9093-71891a900380:indexpattern-datasource-layer-36023ac0-43e8-41de-aee2-ebabf1b4fb65","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac2","name":"ee9dceec-afc3-4258-9998-44a00c2b36fc:panel_ee9dceec-afc3-4258-9998-44a00c2b36fc","type":"lens"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-c0b8fb6e-3ed0-4565-b5f5-635ed8fe4d9f","type":"index-pattern"},{"id":"logstash-*","name":"b7b17dfe-87b7-4c8b-81f5-aab625251c3f:indexpattern-datasource-layer-5dd18300-eb6a-4259-8e68-2e302c2b9bcb","type":"index-pattern"},{"id":"logstash-*","name":"7557df66-cfde-4401-a926-aff27d774715:layer_1_source_index_pattern","type":"index-pattern"},{"id":"76fc4200-cf44-11e9-b933-fd84270f3ac1","name":"d3089be5-dff0-4bbe-9a36-76dd1dec98ef:panel_d3089be5-dff0-4bbe-9a36-76dd1dec98ef","type":"lens"}],"type":"dashboard","typeMigrationVersion":"8.9.0","updated_at":"2023-09-28T17:07:48.232Z","version":"WzI0LDFd"} {"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":4,"missingRefCount":0,"missingReferences":[]} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 55ccb83905494..d85a677823caf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4811,6 +4811,10 @@ version "0.0.0" uid "" +"@kbn/links-plugin@link:src/plugins/links": + version "0.0.0" + uid "" + "@kbn/lint-packages-cli@link:packages/kbn-lint-packages-cli": version "0.0.0" uid ""