diff --git a/examples/dashboard_embeddable_examples/public/app.tsx b/examples/dashboard_embeddable_examples/public/app.tsx index 531a78a35999..8678f1c4df3b 100644 --- a/examples/dashboard_embeddable_examples/public/app.tsx +++ b/examples/dashboard_embeddable_examples/public/app.tsx @@ -16,12 +16,12 @@ import { EuiPageContentBody_Deprecated as EuiPageContentBody, EuiPageSideBar_Deprecated as EuiPageSideBar, EuiSideNav, + EuiTitle, + EuiText, } from '@elastic/eui'; import 'brace/mode/json'; import { AppMountParameters, IUiSettingsClient } from '@kbn/core/public'; -import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { DashboardEmbeddableByValue } from './by_value/embeddable'; interface PageDef { title: string; @@ -56,32 +56,20 @@ const Nav = withRouter(({ history, pages }: NavProps) => { interface Props { basename: string; - DashboardContainerByValueRenderer: ReturnType< - DashboardStart['getDashboardContainerByValueRenderer'] - >; uiSettings: IUiSettingsClient; } -const DashboardEmbeddableExplorerApp = ({ - basename, - DashboardContainerByValueRenderer, - uiSettings, -}: Props) => { +const DashboardEmbeddableExplorerApp = ({ basename, uiSettings }: Props) => { const pages: PageDef[] = [ { - title: 'By value dashboard embeddable', - id: 'dashboardEmbeddableByValue', + title: 'Portable Dashboard basic embeddable example', + id: 'portableDashboardEmbeddableBasicExample', component: ( - + + Portable Dashboard embeddable examples coming soon! + ), }, - { - title: 'By ref dashboard embeddable', - id: 'dashboardEmbeddableByRef', - component:
TODO: Not implemented, but coming soon...
, - }, ]; const routes = pages.map((page, i) => ( diff --git a/examples/dashboard_embeddable_examples/public/by_value/embeddable.tsx b/examples/dashboard_embeddable_examples/public/by_value/embeddable.tsx deleted file mode 100644 index 6107605f2501..000000000000 --- a/examples/dashboard_embeddable_examples/public/by_value/embeddable.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { useState } from 'react'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { DashboardContainerInput, DashboardStart } from '@kbn/dashboard-plugin/public'; -import { HELLO_WORLD_EMBEDDABLE } from '@kbn/embeddable-examples-plugin/public/hello_world'; -import { TODO_EMBEDDABLE } from '@kbn/embeddable-examples-plugin/public/todo'; -import { TODO_REF_EMBEDDABLE } from '@kbn/embeddable-examples-plugin/public/todo/todo_ref_embeddable'; -import { InputEditor } from './input_editor'; - -const initialInput: DashboardContainerInput = { - viewMode: ViewMode.VIEW, - panels: { - '1': { - gridData: { - w: 10, - h: 10, - x: 0, - y: 0, - i: '1', - }, - type: HELLO_WORLD_EMBEDDABLE, - explicitInput: { - id: '1', - }, - }, - '2': { - gridData: { - w: 10, - h: 10, - x: 10, - y: 0, - i: '2', - }, - type: HELLO_WORLD_EMBEDDABLE, - explicitInput: { - id: '2', - }, - }, - '3': { - gridData: { - w: 10, - h: 10, - x: 0, - y: 10, - i: '3', - }, - type: TODO_EMBEDDABLE, - explicitInput: { - id: '3', - title: 'Clean up', - task: 'Clean up the code', - icon: 'trash', - }, - }, - '4': { - gridData: { - w: 10, - h: 10, - x: 10, - y: 10, - i: '4', - }, - type: TODO_REF_EMBEDDABLE, - explicitInput: { - id: '4', - savedObjectId: 'sample-todo-saved-object', - }, - }, - }, - isFullScreenMode: false, - filters: [], - useMargins: false, - id: 'random-id', - timeRange: { - to: 'now', - from: 'now-1d', - }, - timeRestore: false, - title: 'test', - query: { - query: '', - language: 'lucene', - }, - refreshConfig: { - pause: true, - value: 15, - }, -}; - -export const DashboardEmbeddableByValue = ({ - DashboardContainerByValueRenderer, -}: { - DashboardContainerByValueRenderer: ReturnType< - DashboardStart['getDashboardContainerByValueRenderer'] - >; -}) => { - const [input, setInput] = useState(initialInput); - - return ( - <> - - - - ); -}; diff --git a/examples/dashboard_embeddable_examples/public/by_value/input_editor.tsx b/examples/dashboard_embeddable_examples/public/by_value/input_editor.tsx deleted file mode 100644 index 1d205eea0efe..000000000000 --- a/examples/dashboard_embeddable_examples/public/by_value/input_editor.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { EuiButton } from '@elastic/eui'; -import { CodeEditor } from '@kbn/kibana-react-plugin/public'; - -export const InputEditor = (props: { input: T; onSubmit: (value: T) => void }) => { - const input = JSON.stringify(props.input, null, 4); - const [value, setValue] = React.useState(input); - const isValid = (() => { - try { - JSON.parse(value); - return true; - } catch (e) { - return false; - } - })(); - React.useEffect(() => { - setValue(input); - }, [input]); - return ( - <> - setValue(v)} - data-test-subj={'dashboardEmbeddableByValueInputEditor'} - /> - props.onSubmit(JSON.parse(value))} - disabled={!isValid} - data-test-subj={'dashboardEmbeddableByValueInputSubmit'} - > - Update Input - - - ); -}; diff --git a/examples/dashboard_embeddable_examples/public/plugin.tsx b/examples/dashboard_embeddable_examples/public/plugin.tsx index 0c31076df8c6..6c89618775c6 100644 --- a/examples/dashboard_embeddable_examples/public/plugin.tsx +++ b/examples/dashboard_embeddable_examples/public/plugin.tsx @@ -33,8 +33,6 @@ export class DashboardEmbeddableExamples implements Plugin = useMemo(() => React.createRef(), []); const [fatalError, setFatalError] = useState(); - const { - useEmbeddableSelector: select, - containerActions: { untilEmbeddableLoaded, removeEmbeddable }, - } = useReduxContainerContext(); + const { useEmbeddableSelector: select, embeddableInstance: controlGroup } = + useReduxEmbeddableContext< + ControlGroupReduxState, + typeof controlGroupReducers, + ControlGroupContainer + >(); const controlStyle = select((state) => state.explicitInput.controlStyle); @@ -97,7 +101,11 @@ export const ControlFrame = ({ overlays: { openConfirm }, } = pluginServices.getServices(); - const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId, embeddableType }); + const embeddable = useChildEmbeddable({ + untilEmbeddableLoaded: controlGroup.untilEmbeddableLoaded.bind(controlGroup), + embeddableType, + embeddableId, + }); const [title, setTitle] = useState(); @@ -143,7 +151,7 @@ export const ControlFrame = ({ buttonColor: 'danger', }).then((confirmed) => { if (confirmed) { - removeEmbeddable(embeddableId); + controlGroup.removeEmbeddable(embeddableId); } }) } diff --git a/src/plugins/controls/public/control_group/component/control_group_component.tsx b/src/plugins/controls/public/control_group/component/control_group_component.tsx index 18b22aa9007f..52cfb97cca3d 100644 --- a/src/plugins/controls/public/control_group/component/control_group_component.tsx +++ b/src/plugins/controls/public/control_group/component/control_group_component.tsx @@ -30,7 +30,7 @@ import { } from '@dnd-kit/core'; import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; +import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; import { ControlGroupReduxState } from '../types'; import { controlGroupReducers } from '../state/control_group_reducers'; @@ -38,7 +38,7 @@ import { ControlClone, SortableControl } from './control_group_sortable_item'; export const ControlGroup = () => { // Redux embeddable container Context - const reduxContainerContext = useReduxContainerContext< + const reduxContext = useReduxEmbeddableContext< ControlGroupReduxState, typeof controlGroupReducers >(); @@ -46,7 +46,7 @@ export const ControlGroup = () => { actions: { setControlOrders }, useEmbeddableSelector: select, useEmbeddableDispatch, - } = reduxContainerContext; + } = reduxContext; const dispatch = useEmbeddableDispatch(); // current state diff --git a/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx b/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx index 43907b95a893..8a1aee69fd36 100644 --- a/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx +++ b/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx @@ -12,7 +12,7 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import classNames from 'classnames'; -import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; +import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; import { ControlFrame, ControlFrameProps } from './control_frame_component'; import { ControlGroupReduxState } from '../types'; import { ControlGroupStrings } from '../control_group_strings'; @@ -67,7 +67,7 @@ const SortableControlInner = forwardRef< dragHandleRef ) => { const { isOver, isDragging, draggingIndex, index } = dragInfo; - const { useEmbeddableSelector } = useReduxContainerContext(); + const { useEmbeddableSelector } = useReduxEmbeddableContext(); const panels = useEmbeddableSelector((state) => state.explicitInput.panels); const grow = panels[embeddableId].grow; @@ -119,7 +119,7 @@ const SortableControlInner = forwardRef< * can be quite cumbersome. */ export const ControlClone = ({ draggingId }: { draggingId: string }) => { - const { useEmbeddableSelector: select } = useReduxContainerContext(); + const { useEmbeddableSelector: select } = useReduxEmbeddableContext(); const panels = select((state) => state.explicitInput.panels); const controlStyle = select((state) => state.explicitInput.controlStyle); diff --git a/src/plugins/controls/public/control_group/control_group_renderer.tsx b/src/plugins/controls/public/control_group/control_group_renderer.tsx index fc5852cff18e..987c8fcd7e5e 100644 --- a/src/plugins/controls/public/control_group/control_group_renderer.tsx +++ b/src/plugins/controls/public/control_group/control_group_renderer.tsx @@ -11,7 +11,7 @@ import useLifecycles from 'react-use/lib/useLifecycles'; import React, { useMemo, useRef, useState } from 'react'; import { IEmbeddable } from '@kbn/embeddable-plugin/public'; -import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; +import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; import { pluginServices } from '../services'; import { getDefaultControlGroupInput } from '../../common'; @@ -78,7 +78,7 @@ export const ControlGroupRenderer = ({ }; export const useControlGroupContainerContext = () => - useReduxContainerContext(); + useReduxEmbeddableContext(); // required for dynamic import using React.lazy() // eslint-disable-next-line import/no-default-export diff --git a/src/plugins/controls/public/control_group/editor/edit_control.tsx b/src/plugins/controls/public/control_group/editor/edit_control.tsx index a257339772a7..af5564442569 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control.tsx @@ -13,7 +13,7 @@ import React, { useEffect, useRef } from 'react'; import { OverlayRef } from '@kbn/core/public'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public'; -import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; +import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; import { ControlGroupReduxState } from '../types'; import { ControlEditor } from './control_editor'; import { pluginServices } from '../../services'; @@ -40,16 +40,17 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => theme: { theme$ }, } = pluginServices.getServices(); // Redux embeddable container Context - const reduxContainerContext = useReduxContainerContext< + const reduxContext = useReduxEmbeddableContext< ControlGroupReduxState, - typeof controlGroupReducers + typeof controlGroupReducers, + ControlGroupContainer >(); const { - containerActions: { untilEmbeddableLoaded, removeEmbeddable, replaceEmbeddable }, + embeddableInstance: controlGroup, actions: { setControlWidth, setControlGrow }, useEmbeddableSelector, useEmbeddableDispatch, - } = reduxContainerContext; + } = reduxContext; const dispatch = useEmbeddableDispatch(); // current state @@ -63,7 +64,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const editControl = async () => { const ControlsServicesProvider = pluginServices.getContextProvider(); - const embeddable = (await untilEmbeddableLoaded( + const embeddable = (await controlGroup.untilEmbeddableLoaded( embeddableId )) as ControlEmbeddable; @@ -72,8 +73,6 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => let factory = getControlFactory(panel.type); if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); - const controlGroup = embeddable.getRoot() as ControlGroupContainer; - let inputToReturn: Partial = {}; let removed = false; @@ -153,7 +152,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => buttonColor: 'danger', }).then((confirmed) => { if (confirmed) { - removeEmbeddable(embeddableId); + controlGroup.removeEmbeddable(embeddableId); removed = true; flyoutInstance.close(); } @@ -177,7 +176,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => initialInputPromise.then( async (promise) => { - await replaceEmbeddable(embeddable.id, promise.controlInput, promise.type); + await controlGroup.replaceEmbeddable(embeddable.id, promise.controlInput, promise.type); }, () => {} // swallow promise rejection because it can be part of normal flow ); diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts b/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts index 1857bd8a8df0..b5e703afefd1 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts +++ b/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts @@ -35,7 +35,7 @@ interface OnChildChangedProps { interface ChainingSystem { getContainerSettings: ( initialInput: ControlGroupInput - ) => EmbeddableContainerSettings | undefined; + ) => EmbeddableContainerSettings | undefined; getPrecedingFilters: ( props: GetPrecedingFiltersProps ) => { filters: Filter[]; timeslice?: [number, number] } | undefined; diff --git a/src/plugins/dashboard/common/persistable_state/dashboard_container_references.test.ts b/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.test.ts similarity index 98% rename from src/plugins/dashboard/common/persistable_state/dashboard_container_references.test.ts rename to src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.test.ts index 47215e5e3200..a6411cef1ebb 100644 --- a/src/plugins/dashboard/common/persistable_state/dashboard_container_references.test.ts +++ b/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.test.ts @@ -8,7 +8,7 @@ import { createExtract, createInject } from './dashboard_container_references'; import { createEmbeddablePersistableStateServiceMock } from '@kbn/embeddable-plugin/common/mocks'; -import { DashboardContainerStateWithType } from '../types'; +import { DashboardContainerStateWithType } from '../../types'; const persistableStateService = createEmbeddablePersistableStateServiceMock(); diff --git a/src/plugins/dashboard/common/persistable_state/dashboard_container_references.ts b/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.ts similarity index 98% rename from src/plugins/dashboard/common/persistable_state/dashboard_container_references.ts rename to src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.ts index 5346932f7034..a74afc07c473 100644 --- a/src/plugins/dashboard/common/persistable_state/dashboard_container_references.ts +++ b/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.ts @@ -13,7 +13,8 @@ import { } from '@kbn/embeddable-plugin/common'; import { SavedObjectReference } from '@kbn/core/types'; import { CONTROL_GROUP_TYPE, PersistableControlGroupInput } from '@kbn/controls-plugin/common'; -import { DashboardContainerStateWithType, DashboardPanelState } from '../types'; +import { DashboardPanelState } from '../types'; +import { DashboardContainerStateWithType } from '../../types'; const getPanelStatePrefix = (state: DashboardPanelState) => `${state.explicitInput.id}:`; diff --git a/src/plugins/dashboard/common/dashboard_container/type_guards.ts b/src/plugins/dashboard/common/dashboard_container/type_guards.ts new file mode 100644 index 000000000000..1c5468609c3a --- /dev/null +++ b/src/plugins/dashboard/common/dashboard_container/type_guards.ts @@ -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 type { + DashboardContainerByReferenceInput, + DashboardContainerByValueInput, + DashboardContainerInput, +} from './types'; + +export const dashboardContainerInputIsByValue = ( + containerInput: DashboardContainerInput +): containerInput is DashboardContainerByValueInput => { + if ( + (containerInput as DashboardContainerByValueInput).panels && + !(containerInput as DashboardContainerByReferenceInput).savedObjectId + ) { + return true; + } + return false; +}; diff --git a/src/plugins/dashboard/common/dashboard_container/types.ts b/src/plugins/dashboard/common/dashboard_container/types.ts new file mode 100644 index 000000000000..df71eb456544 --- /dev/null +++ b/src/plugins/dashboard/common/dashboard_container/types.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + ViewMode, + PanelState, + EmbeddableInput, + SavedObjectEmbeddableInput, +} from '@kbn/embeddable-plugin/common'; +import { Filter, Query, TimeRange } from '@kbn/es-query'; +import { RefreshInterval } from '@kbn/data-plugin/common'; +import { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; +import { KibanaExecutionContext } from '@kbn/core-execution-context-common'; + +import { DashboardOptions, GridData } from '../types'; + +export interface DashboardPanelMap { + [key: string]: DashboardPanelState; +} + +export interface DashboardPanelState< + TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput +> extends PanelState { + readonly gridData: GridData; + panelRefName?: string; +} + +export type DashboardContainerInput = + | DashboardContainerByReferenceInput + | DashboardContainerByValueInput; + +export type DashboardContainerByReferenceInput = SavedObjectEmbeddableInput & { panels: never }; + +export interface DashboardContainerByValueInput extends EmbeddableInput { + // filter context to be passed to children + query: Query; + filters: Filter[]; + timeRestore: boolean; + timeRange?: TimeRange; + timeslice?: [number, number]; + refreshInterval?: RefreshInterval; + + // dashboard meta info + title: string; + tags: string[]; + viewMode: ViewMode; + description?: string; + isEmbeddedExternally?: boolean; + executionContext?: KibanaExecutionContext; + + // dashboard options: TODO, build a new system to avoid all shared state appearing here. See https://github.com/elastic/kibana/issues/144532 for more information. + hidePanelTitles: DashboardOptions['hidePanelTitles']; + syncTooltips: DashboardOptions['syncTooltips']; + useMargins: DashboardOptions['useMargins']; + syncColors: DashboardOptions['syncColors']; + syncCursor: DashboardOptions['syncCursor']; + + // dashboard contents + controlGroupInput?: PersistableControlGroupInput; + panels: DashboardPanelMap; +} diff --git a/src/plugins/dashboard/common/persistable_state/dashboard_saved_object_references.test.ts b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts similarity index 98% rename from src/plugins/dashboard/common/persistable_state/dashboard_saved_object_references.test.ts rename to src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts index e28a429d6d00..7a8f28a293c6 100644 --- a/src/plugins/dashboard/common/persistable_state/dashboard_saved_object_references.test.ts +++ b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts @@ -13,7 +13,10 @@ import { ExtractDeps, } from './dashboard_saved_object_references'; -import { createExtract, createInject } from './dashboard_container_references'; +import { + createExtract, + createInject, +} from '../../dashboard_container/persistable_state/dashboard_container_references'; import { createEmbeddablePersistableStateServiceMock } from '@kbn/embeddable-plugin/common/mocks'; const embeddablePersistableStateServiceMock = createEmbeddablePersistableStateServiceMock(); diff --git a/src/plugins/dashboard/common/persistable_state/dashboard_saved_object_references.ts b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts similarity index 96% rename from src/plugins/dashboard/common/persistable_state/dashboard_saved_object_references.ts rename to src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts index a3126a381d94..88017bb6d550 100644 --- a/src/plugins/dashboard/common/persistable_state/dashboard_saved_object_references.ts +++ b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts @@ -14,15 +14,13 @@ import { import { SavedObjectAttributes, SavedObjectReference } from '@kbn/core/types'; import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common/types'; -import { - SavedDashboardPanel, - DashboardPanelState, - DashboardContainerStateWithType, -} from '../types'; +import { SavedDashboardPanel } from '../types'; import { convertPanelStateToSavedDashboardPanel, convertSavedDashboardPanelToPanelState, -} from '../lib/dashboard_panel_converters'; +} from '../../lib/dashboard_panel_converters'; +import { DashboardPanelState } from '../../dashboard_container/types'; +import { DashboardContainerStateWithType } from '../../types'; export interface ExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; diff --git a/src/plugins/dashboard/common/dashboard_saved_object/types.ts b/src/plugins/dashboard/common/dashboard_saved_object/types.ts new file mode 100644 index 000000000000..9d859ef660e8 --- /dev/null +++ b/src/plugins/dashboard/common/dashboard_saved_object/types.ts @@ -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 { RefreshInterval } from '@kbn/data-plugin/common'; +import { RawControlGroupAttributes } from '@kbn/controls-plugin/common'; + +import { Serializable } from '@kbn/utility-types'; +import { DashboardOptions, GridData } from '../types'; + +/** + * The attributes of the dashboard saved object. This interface should be the + * source of truth for the latest dashboard attributes shape after all migrations. + */ +export interface DashboardAttributes { + controlGroupInput?: RawControlGroupAttributes; + refreshInterval?: RefreshInterval; + timeRestore: boolean; + optionsJSON?: string; + useMargins?: boolean; + description: string; + panelsJSON: string; + timeFrom?: string; + version: number; + timeTo?: string; + title: string; + kibanaSavedObjectMeta: { + searchSourceJSON: string; + }; +} + +export type ParsedDashboardAttributes = Omit & { + panels: SavedDashboardPanel[]; + options: DashboardOptions; +}; + +/** + * A saved dashboard panel parsed directly from the Dashboard Attributes panels JSON + */ +export interface SavedDashboardPanel { + embeddableConfig: { [key: string]: Serializable }; // parsed into the panel's explicitInput + id?: string; // the saved object id for by reference panels + type: string; // the embeddable type + panelRefName?: string; + gridData: GridData; + panelIndex: string; + version: string; + title?: string; +} diff --git a/src/plugins/dashboard/common/index.ts b/src/plugins/dashboard/common/index.ts index 81833f8a8f18..b997db4d7696 100644 --- a/src/plugins/dashboard/common/index.ts +++ b/src/plugins/dashboard/common/index.ts @@ -8,19 +8,34 @@ export type { GridData, + DashboardOptions, + DashboardCapabilities, + SharedDashboardState, +} from './types'; + +export type { DashboardPanelMap, - SavedDashboardPanel, - DashboardAttributes, DashboardPanelState, - DashboardContainerStateWithType, -} from './types'; + DashboardContainerInput, + DashboardContainerByValueInput, + DashboardContainerByReferenceInput, +} from './dashboard_container/types'; + +export type { + DashboardAttributes, + ParsedDashboardAttributes, + SavedDashboardPanel, +} from './dashboard_saved_object/types'; export { injectReferences, extractReferences, -} from './persistable_state/dashboard_saved_object_references'; +} from './dashboard_saved_object/persistable_state/dashboard_saved_object_references'; -export { createInject, createExtract } from './persistable_state/dashboard_container_references'; +export { + createInject, + createExtract, +} from './dashboard_container/persistable_state/dashboard_container_references'; export { convertPanelStateToSavedDashboardPanel, diff --git a/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts b/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts index 2ebca116f3f1..7683af957e83 100644 --- a/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts +++ b/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts @@ -10,8 +10,9 @@ import { convertSavedDashboardPanelToPanelState, convertPanelStateToSavedDashboardPanel, } from './dashboard_panel_converters'; -import { SavedDashboardPanel, DashboardPanelState } from '../types'; import { EmbeddableInput } from '@kbn/embeddable-plugin/common/types'; +import { SavedDashboardPanel } from '../dashboard_saved_object/types'; +import { DashboardPanelState } from '../dashboard_container/types'; test('convertSavedDashboardPanelToPanelState', () => { const savedDashboardPanel: SavedDashboardPanel = { diff --git a/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts b/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts index 2652c7f9a40a..15f991f5ac70 100644 --- a/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts +++ b/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts @@ -8,7 +8,8 @@ import { omit } from 'lodash'; import { EmbeddableInput, SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common'; -import { DashboardPanelMap, DashboardPanelState, SavedDashboardPanel } from '../types'; + +import { DashboardPanelMap, DashboardPanelState, SavedDashboardPanel } from '..'; export function convertSavedDashboardPanelToPanelState< TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index ff5a1cbc1755..4a1abbd49b2d 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -6,18 +6,19 @@ * Side Public License, v 1. */ -import { - EmbeddableInput, - EmbeddableStateWithType, - PanelState, -} from '@kbn/embeddable-plugin/common/types'; -import { Serializable } from '@kbn/utility-types'; -import { - PersistableControlGroupInput, - RawControlGroupAttributes, -} from '@kbn/controls-plugin/common'; -import { RefreshInterval } from '@kbn/data-plugin/common'; -import { SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common/lib/saved_object_embeddable'; +import { EmbeddableInput, EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; +import { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; + +import { SavedDashboardPanel } from './dashboard_saved_object/types'; +import { DashboardContainerByValueInput, DashboardPanelState } from './dashboard_container/types'; + +export interface DashboardOptions { + hidePanelTitles: boolean; + useMargins: boolean; + syncColors: boolean; + syncTooltips: boolean; + syncCursor: boolean; +} export interface DashboardCapabilities { showWriteControls: boolean; @@ -28,53 +29,11 @@ export interface DashboardCapabilities { } /** - * The attributes of the dashboard saved object. This interface should be the - * source of truth for the latest dashboard attributes shape after all migrations. - */ -export interface DashboardAttributes { - controlGroupInput?: RawControlGroupAttributes; - refreshInterval?: RefreshInterval; - timeRestore: boolean; - optionsJSON?: string; - useMargins?: boolean; - description: string; - panelsJSON: string; - timeFrom?: string; - version: number; - timeTo?: string; - title: string; - kibanaSavedObjectMeta: { - searchSourceJSON: string; - }; -} - -/** -------------------------------------------------------------------- - * Dashboard panel types - -----------------------------------------------------------------------*/ - -/** - * The dashboard panel format expected by the embeddable container. + * For BWC reasons, dashboard state is stored with panels as an array instead of a map */ -export interface DashboardPanelState< - TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput -> extends PanelState { - readonly gridData: GridData; - panelRefName?: string; -} - -/** - * A saved dashboard panel parsed directly from the Dashboard Attributes panels JSON - */ -export interface SavedDashboardPanel { - embeddableConfig: { [key: string]: Serializable }; // parsed into the panel's explicitInput - id?: string; // the saved object id for by reference panels - type: string; // the embeddable type - panelRefName?: string; - gridData: GridData; - panelIndex: string; - version: string; - title?: string; -} +export type SharedDashboardState = Partial< + Omit & { panels: SavedDashboardPanel[] } +>; /** * Grid type for React Grid Layout @@ -87,17 +46,12 @@ export interface GridData { i: string; } -export interface DashboardPanelMap { - [key: string]: DashboardPanelState; -} - -/** -------------------------------------------------------------------- - * Dashboard container types - -----------------------------------------------------------------------*/ - /** * Types below this line are copied here because so many important types are tied up in public. These types should be * moved from public into common. + * + * TODO replace this type with a type that uses the real Dashboard Input type. + * See https://github.com/elastic/kibana/issues/147488 for more information. */ export interface DashboardContainerStateWithType extends EmbeddableStateWithType { panels: { diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx deleted file mode 100644 index b05944c99292..000000000000 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ /dev/null @@ -1,173 +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 { History } from 'history'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; - -import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; -import { EmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public'; -import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public'; - -import { - dashboardFeatureCatalog, - getDashboardBreadcrumb, - getDashboardTitle, - leaveConfirmStrings, -} from '../dashboard_strings'; -import { useDashboardAppState } from './hooks'; -import { useDashboardSelector } from './state'; -import { pluginServices } from '../services/plugin_services'; -import { DashboardAppNoDataPage } from './dashboard_app_no_data'; -import { DashboardEmbedSettings, DashboardRedirect } from '../types'; -import { useDashboardMountContext } from './hooks/dashboard_mount_context'; -import { DashboardTopNav, isCompleteDashboardAppState } from './top_nav/dashboard_top_nav'; -export interface DashboardAppProps { - history: History; - savedDashboardId?: string; - redirectTo: DashboardRedirect; - embedSettings?: DashboardEmbedSettings; -} - -export function DashboardApp({ - savedDashboardId, - embedSettings, - redirectTo, - history, -}: DashboardAppProps) { - const { onAppLeave } = useDashboardMountContext(); - const { - chrome: { setBreadcrumbs, setIsVisible }, - screenshotMode: { isScreenshotMode }, - coreContext: { executionContext }, - embeddable: { getStateTransfer }, - notifications: { toasts }, - settings: { uiSettings }, - data: { search }, - } = pluginServices.getServices(); - - const [showNoDataPage, setShowNoDataPage] = useState(false); - const dashboardTitleRef = useRef(null); - - const kbnUrlStateStorage = useMemo( - () => - createKbnUrlStateStorage({ - history, - useHash: uiSettings.get('state:storeInSessionStorage'), - ...withNotifyOnErrors(toasts), - }), - [toasts, history, uiSettings] - ); - - useExecutionContext(executionContext, { - type: 'application', - page: 'app', - id: savedDashboardId || 'new', - }); - - const dashboardState = useDashboardSelector((state) => state.dashboardStateReducer); - const dashboardAppState = useDashboardAppState({ - history, - showNoDataPage, - setShowNoDataPage, - savedDashboardId, - kbnUrlStateStorage, - isEmbeddedExternally: Boolean(embedSettings), - }); - - // focus on the top header when title or view mode is changed - useEffect(() => { - dashboardTitleRef.current?.focus(); - }, [dashboardState.title, dashboardState.viewMode]); - - const dashboardTitle = useMemo(() => { - return getDashboardTitle(dashboardState.title, dashboardState.viewMode, !savedDashboardId); - }, [dashboardState.title, dashboardState.viewMode, savedDashboardId]); - - // Build app leave handler whenever hasUnsavedChanges changes - useEffect(() => { - onAppLeave((actions) => { - if (dashboardAppState.hasUnsavedChanges && !getStateTransfer().isTransferInProgress) { - return actions.confirm( - leaveConfirmStrings.getLeaveSubtitle(), - leaveConfirmStrings.getLeaveTitle() - ); - } - return actions.default(); - }); - return () => { - // reset on app leave handler so leaving from the listing page doesn't trigger a confirmation - onAppLeave((actions) => actions.default()); - }; - }, [onAppLeave, getStateTransfer, dashboardAppState.hasUnsavedChanges]); - - // Set breadcrumbs when dashboard's title or view mode changes - useEffect(() => { - if (!dashboardState.title && savedDashboardId) return; - setBreadcrumbs([ - { - text: getDashboardBreadcrumb(), - 'data-test-subj': 'dashboardListingBreadcrumb', - onClick: () => { - redirectTo({ destination: 'listing' }); - }, - }, - { - text: dashboardTitle, - }, - ]); - }, [setBreadcrumbs, dashboardState.title, redirectTo, savedDashboardId, dashboardTitle]); - - // clear search session when leaving dashboard route - useEffect(() => { - return () => { - search.session.clear(); - }; - }, [search.session]); - - const printMode = useMemo( - () => dashboardAppState.getLatestDashboardState?.().viewMode === ViewMode.PRINT, - [dashboardAppState] - ); - - useEffect(() => { - if (!embedSettings) setIsVisible(!printMode); - }, [setIsVisible, printMode, embedSettings]); - - return ( - <> -

{`${dashboardFeatureCatalog.getTitle()} - ${dashboardTitle}`}

- {showNoDataPage && ( - setShowNoDataPage(false)} /> - )} - {!showNoDataPage && isCompleteDashboardAppState(dashboardAppState) && ( - <> - - - {dashboardAppState.createConflictWarning?.()} -
- -
- - )} - - ); -} diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx deleted file mode 100644 index 7c76b8c00e55..000000000000 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ /dev/null @@ -1,385 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import uuid from 'uuid'; -import ReactDOM from 'react-dom'; - -import { I18nProvider } from '@kbn/i18n-react'; -import { Subscription } from 'rxjs'; -import type { KibanaExecutionContext } from '@kbn/core/public'; -import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; -import type { ControlGroupContainer } from '@kbn/controls-plugin/public'; -import type { Filter, TimeRange } from '@kbn/es-query'; -import type { DataView } from '@kbn/data-views-plugin/public'; -import { - ViewMode, - Container, - type PanelState, - type IEmbeddable, - type EmbeddableInput, - type EmbeddableOutput, - type EmbeddableFactory, - ErrorEmbeddable, - isErrorEmbeddable, -} from '@kbn/embeddable-plugin/public'; -import type { Query } from '@kbn/es-query'; -import type { RefreshInterval } from '@kbn/data-plugin/public'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; -import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen'; - -import { DASHBOARD_CONTAINER_TYPE } from '../../dashboard_constants'; -import { createPanelState } from './panel'; -import { DashboardPanelState } from './types'; -import { DashboardViewport } from './viewport/dashboard_viewport'; -import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; -import { DASHBOARD_LOADED_EVENT } from '../../events'; -import { DashboardContainerInput } from '../../types'; -import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; -import { - combineDashboardFiltersWithControlGroupFilters, - syncDashboardControlGroup, -} from '../lib/dashboard_control_group'; -import { pluginServices } from '../../services/plugin_services'; - -export interface DashboardLoadedInfo { - timeToData: number; - timeToDone: number; - numOfPanels: number; - status: string; -} - -interface IndexSignature { - [key: string]: unknown; -} - -export interface InheritedChildInput extends IndexSignature { - filters: Filter[]; - query: Query; - timeRange: TimeRange; - timeslice?: [number, number]; - refreshConfig?: RefreshInterval; - viewMode: ViewMode; - hidePanelTitles?: boolean; - id: string; - searchSessionId?: string; - syncColors?: boolean; - syncCursor?: boolean; - syncTooltips?: boolean; - executionContext?: KibanaExecutionContext; -} - -export class DashboardContainer extends Container { - public readonly type = DASHBOARD_CONTAINER_TYPE; - - private onDestroyControlGroup?: () => void; - private subscriptions: Subscription = new Subscription(); - - public controlGroup?: ControlGroupContainer; - private domNode?: HTMLElement; - - private allDataViews: DataView[] = []; - - /** Services that are used in the Dashboard container code */ - private analyticsService; - private chrome; - private theme$; - - /** - * Gets all the dataviews that are actively being used in the dashboard - * @returns An array of dataviews - */ - public getAllDataViews = () => { - return this.allDataViews; - }; - - /** - * Use this to set the dataviews that are used in the dashboard when they change/update - * @param newDataViews The new array of dataviews that will overwrite the old dataviews array - */ - public setAllDataViews = (newDataViews: DataView[]) => { - this.allDataViews = newDataViews; - }; - - public getPanelCount = () => { - return Object.keys(this.getInput().panels).length; - }; - - public async getPanelTitles(): Promise { - const titles: string[] = []; - const ids: string[] = Object.keys(this.getInput().panels); - for (const panelId of ids) { - await this.untilEmbeddableLoaded(panelId); - const child: IEmbeddable = this.getChild(panelId); - const title = child.getTitle(); - if (title) { - titles.push(title); - } - } - return titles; - } - - constructor( - initialInput: DashboardContainerInput, - parent?: Container, - controlGroup?: ControlGroupContainer | ErrorEmbeddable - ) { - const { - embeddable: { getEmbeddableFactory }, - settings: { isProjectEnabledInLabs }, - } = pluginServices.getServices(); - - super( - { - ...initialInput, - }, - { embeddableLoaded: {} }, - getEmbeddableFactory, - parent - ); - - ({ - analytics: this.analyticsService, - settings: { - theme: { theme$: this.theme$ }, - }, - chrome: this.chrome, - } = pluginServices.getServices()); - - if ( - controlGroup && - !isErrorEmbeddable(controlGroup) && - isProjectEnabledInLabs('labs:dashboard:dashboardControls') - ) { - this.controlGroup = controlGroup; - syncDashboardControlGroup({ - dashboardContainer: this, - controlGroup: this.controlGroup, - }).then((result) => { - if (!result) return; - const { onDestroyControlGroup } = result; - this.onDestroyControlGroup = onDestroyControlGroup; - }); - } - - this.subscriptions.add( - this.getAnyChildOutputChange$().subscribe(() => { - if (!this.controlGroup) { - return; - } - - for (const child of Object.values(this.children)) { - const isLoading = child.getOutput().loading; - if (isLoading) { - this.controlGroup.anyControlOutputConsumerLoading$.next(true); - return; - } - } - this.controlGroup.anyControlOutputConsumerLoading$.next(false); - }) - ); - } - - private onDataLoaded(data: DashboardLoadedInfo) { - if (this.analyticsService) { - reportPerformanceMetricEvent(this.analyticsService, { - eventName: DASHBOARD_LOADED_EVENT, - duration: data.timeToDone, - key1: 'time_to_data', - value1: data.timeToData, - key2: 'num_of_panels', - value2: data.numOfPanels, - }); - } - } - - protected createNewPanelState< - TEmbeddableInput extends EmbeddableInput, - TEmbeddable extends IEmbeddable - >( - factory: EmbeddableFactory, - partial: Partial = {} - ): DashboardPanelState { - const panelState = super.createNewPanelState(factory, partial); - const { newPanel } = createPanelState(panelState, this.input.panels); - return newPanel; - } - - public showPlaceholderUntil( - newStateComplete: Promise>, - placementMethod?: PanelPlacementMethod, - placementArgs?: TPlacementMethodArgs - ): void { - const originalPanelState = { - type: PLACEHOLDER_EMBEDDABLE, - explicitInput: { - id: uuid.v4(), - disabledActions: [ - 'ACTION_CUSTOMIZE_PANEL', - 'CUSTOM_TIME_RANGE', - 'clonePanel', - 'replacePanel', - 'togglePanel', - ], - }, - } as PanelState; - - const { otherPanels, newPanel: placeholderPanelState } = createPanelState( - originalPanelState, - this.input.panels, - placementMethod, - placementArgs - ); - - this.updateInput({ - panels: { - ...otherPanels, - [placeholderPanelState.explicitInput.id]: placeholderPanelState, - }, - }); - - // wait until the placeholder is ready, then replace it with new panel - // this is useful as sometimes panels can load faster than the placeholder one (i.e. by value embeddables) - this.untilEmbeddableLoaded(originalPanelState.explicitInput.id) - .then(() => newStateComplete) - .then((newPanelState: Partial) => - this.replacePanel(placeholderPanelState, newPanelState) - ); - } - - public replacePanel( - previousPanelState: DashboardPanelState, - newPanelState: Partial, - generateNewId?: boolean - ) { - let panels; - if (generateNewId) { - // replace panel can be called with generateNewId in order to totally destroy and recreate the embeddable - panels = { ...this.input.panels }; - delete panels[previousPanelState.explicitInput.id]; - const newId = uuid.v4(); - panels[newId] = { - ...previousPanelState, - ...newPanelState, - gridData: { - ...previousPanelState.gridData, - i: newId, - }, - explicitInput: { - ...newPanelState.explicitInput, - id: newId, - }, - }; - } else { - // Because the embeddable type can change, we have to operate at the container level here - panels = { - ...this.input.panels, - [previousPanelState.explicitInput.id]: { - ...previousPanelState, - ...newPanelState, - gridData: { - ...previousPanelState.gridData, - }, - explicitInput: { - ...newPanelState.explicitInput, - id: previousPanelState.explicitInput.id, - }, - }, - }; - } - - return this.updateInput({ - panels, - lastReloadRequestTime: new Date().getTime(), - }); - } - - public async addOrUpdateEmbeddable< - EEI extends EmbeddableInput = EmbeddableInput, - EEO extends EmbeddableOutput = EmbeddableOutput, - E extends IEmbeddable = IEmbeddable - >(type: string, explicitInput: Partial, embeddableId?: string) { - const idToReplace = embeddableId || explicitInput.id; - if (idToReplace && this.input.panels[idToReplace]) { - return this.replacePanel(this.input.panels[idToReplace], { - type, - explicitInput: { - ...explicitInput, - id: idToReplace, - }, - }); - } - return this.addNewEmbeddable(type, explicitInput); - } - - public render(dom: HTMLElement) { - if (this.domNode) { - ReactDOM.unmountComponentAtNode(this.domNode); - } - this.domNode = dom; - - ReactDOM.render( - - - - - - - , - dom - ); - } - - public destroy() { - super.destroy(); - this.subscriptions.unsubscribe(); - this.onDestroyControlGroup?.(); - if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); - } - - protected getInheritedInput(id: string): InheritedChildInput { - const { - viewMode, - refreshConfig, - timeRange, - timeslice, - query, - hidePanelTitles, - filters, - searchSessionId, - syncColors, - syncCursor, - syncTooltips, - executionContext, - } = this.input; - - let combinedFilters = filters; - if (this.controlGroup) { - combinedFilters = combineDashboardFiltersWithControlGroupFilters(filters, this.controlGroup); - } - return { - filters: combinedFilters, - hidePanelTitles, - query, - timeRange, - timeslice, - refreshConfig, - viewMode, - id, - searchSessionId, - syncColors, - syncTooltips, - syncCursor, - executionContext, - }; - } -} diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx deleted file mode 100644 index 8b6270360089..000000000000 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx +++ /dev/null @@ -1,29 +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 * as React from 'react'; -import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; -import { DashboardContainerInput } from '../..'; -import { DashboardContainerFactory } from './dashboard_container_factory'; - -interface Props { - input: DashboardContainerInput; - onInputUpdated?: (newInput: DashboardContainerInput) => void; - // TODO: add other props as needed -} - -export const createDashboardContainerByValueRenderer = - ({ factory }: { factory: DashboardContainerFactory }): React.FC => - (props: Props) => - ( - - ); diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx deleted file mode 100644 index de25dba416d5..000000000000 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { identity, pickBy } from 'lodash'; - -import { - ControlGroupContainer, - ControlGroupInput, - ControlGroupOutput, - CONTROL_GROUP_TYPE, -} from '@kbn/controls-plugin/public'; -import { - Container, - ErrorEmbeddable, - ContainerOutput, - EmbeddableFactory, - EmbeddableFactoryDefinition, - isErrorEmbeddable, -} from '@kbn/embeddable-plugin/public'; - -import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common'; -import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; - -import { DashboardContainerInput } from '../..'; -import { createExtract, createInject } from '../../../common'; -import type { DashboardContainer } from './dashboard_container'; -import { DASHBOARD_CONTAINER_TYPE } from '../../dashboard_constants'; - -export type DashboardContainerFactory = EmbeddableFactory< - DashboardContainerInput, - ContainerOutput, - DashboardContainer ->; -export class DashboardContainerFactoryDefinition - implements - EmbeddableFactoryDefinition -{ - public readonly isContainerType = true; - public readonly type = DASHBOARD_CONTAINER_TYPE; - - public inject: EmbeddablePersistableStateService['inject']; - public extract: EmbeddablePersistableStateService['extract']; - - constructor(private readonly persistableStateService: EmbeddablePersistableStateService) { - this.inject = createInject(this.persistableStateService); - this.extract = createExtract(this.persistableStateService); - } - - public isEditable = async () => { - // Currently unused for dashboards - return false; - }; - - public readonly getDisplayName = () => { - return i18n.translate('dashboard.factory.displayName', { - defaultMessage: 'Dashboard', - }); - }; - - public getDefaultInput(): Partial { - return { - panels: {}, - isEmbeddedExternally: false, - isFullScreenMode: false, - useMargins: true, - syncColors: true, - syncCursor: true, - syncTooltips: true, - }; - } - - private buildControlGroup = async ( - initialInput: DashboardContainerInput - ): Promise => { - const { pluginServices } = await import('../../services/plugin_services'); - const { - embeddable: { getEmbeddableFactory }, - } = pluginServices.getServices(); - const controlsGroupFactory = getEmbeddableFactory< - ControlGroupInput, - ControlGroupOutput, - ControlGroupContainer - >(CONTROL_GROUP_TYPE); - const { filters, query, timeRange, viewMode, controlGroupInput, id } = initialInput; - const controlGroup = await controlsGroupFactory?.create({ - id: `control_group_${id ?? 'new_dashboard'}`, - ...getDefaultControlGroupInput(), - ...pickBy(controlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults - timeRange, - viewMode, - filters, - query, - }); - if (controlGroup && !isErrorEmbeddable(controlGroup)) { - await controlGroup.untilInitialized(); - } - return controlGroup; - }; - - public create = async ( - initialInput: DashboardContainerInput, - parent?: Container - ): Promise => { - const controlGroupPromise = this.buildControlGroup(initialInput); - const dashboardContainerPromise = import('./dashboard_container'); - - const [controlGroup, { DashboardContainer: DashboardContainerEmbeddable }] = await Promise.all([ - controlGroupPromise, - dashboardContainerPromise, - ]); - - return Promise.resolve(new DashboardContainerEmbeddable(initialInput, parent, controlGroup)); - }; -} diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx deleted file mode 100644 index 7fda6eb1a3f3..000000000000 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx +++ /dev/null @@ -1,314 +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 _ from 'lodash'; -import React from 'react'; -import sizeMe from 'react-sizeme'; -import classNames from 'classnames'; -import { Subscription } from 'rxjs'; -import 'react-resizable/css/styles.css'; -import 'react-grid-layout/css/styles.css'; -import ReactGridLayout, { Layout, ReactGridLayoutProps } from 'react-grid-layout'; - -import { injectI18n } from '@kbn/i18n-react'; -import { ViewMode, EmbeddablePhaseEvent } from '@kbn/embeddable-plugin/public'; - -import { DashboardContainer, DashboardLoadedInfo } from '../dashboard_container'; -import { GridData } from '../../../../common'; -import { DashboardGridItem } from './dashboard_grid_item'; -import { DashboardLoadedEventStatus, DashboardPanelState } from '../types'; -import { DASHBOARD_GRID_COLUMN_COUNT, DASHBOARD_GRID_HEIGHT } from '../../../dashboard_constants'; -import { pluginServices } from '../../../services/plugin_services'; -import { dashboardSavedObjectErrorStrings } from '../../../dashboard_strings'; - -let lastValidGridSize = 0; - -/** - * This is a fix for a bug that stopped the browser window from automatically scrolling down when panels were made - * taller than the current grid. - * see https://github.com/elastic/kibana/issues/14710. - */ -function ensureWindowScrollsToBottom(event: { clientY: number; pageY: number }) { - // The buffer is to handle the case where the browser is maximized and it's impossible for the mouse to move below - // the screen, out of the window. see https://github.com/elastic/kibana/issues/14737 - const WINDOW_BUFFER = 10; - if (event.clientY > window.innerHeight - WINDOW_BUFFER) { - window.scrollTo(0, event.pageY + WINDOW_BUFFER - window.innerHeight); - } -} - -function ResponsiveGrid({ - size, - isViewMode, - layout, - onLayoutChange, - children, - maximizedPanelId, - useMargins, -}: { - size: { width: number }; - isViewMode: boolean; - layout: Layout[]; - onLayoutChange: ReactGridLayoutProps['onLayoutChange']; - children: JSX.Element[]; - maximizedPanelId?: string; - useMargins: boolean; -}) { - // This is to prevent a bug where view mode changes when the panel is expanded. View mode changes will trigger - // the grid to re-render, but when a panel is expanded, the size will be 0. Minimizing the panel won't cause the - // grid to re-render so it'll show a grid with a width of 0. - lastValidGridSize = size.width > 0 ? size.width : lastValidGridSize; - const classes = classNames({ - 'dshLayout--viewing': isViewMode, - 'dshLayout--editing': !isViewMode, - 'dshLayout-isMaximizedPanel': maximizedPanelId !== undefined, - 'dshLayout-withoutMargins': !useMargins, - }); - - const MARGINS = useMargins ? 8 : 0; - // We can't take advantage of isDraggable or isResizable due to performance concerns: - // https://github.com/STRML/react-grid-layout/issues/240 - return ( - ensureWindowScrollsToBottom(event)} - > - {children} - - ); -} - -// Using sizeMe sets up the grid to be re-rendered automatically not only when the window size changes, but also -// when the container size changes, so it works for Full Screen mode switches. -const config = { monitorWidth: true }; -const ResponsiveSizedGrid = sizeMe(config)(ResponsiveGrid); - -export interface DashboardGridProps extends ReactIntl.InjectedIntlProps { - container: DashboardContainer; - onDataLoaded?: (data: DashboardLoadedInfo) => void; -} - -interface State { - focusedPanelIndex?: string; - isLayoutInvalid: boolean; - layout?: GridData[]; - panels: { [key: string]: DashboardPanelState }; - viewMode: ViewMode; - useMargins: boolean; - expandedPanelId?: string; -} - -interface PanelLayout extends Layout { - i: string; -} - -class DashboardGridUi extends React.Component { - private subscription?: Subscription; - private mounted: boolean = false; - - constructor(props: DashboardGridProps) { - super(props); - - this.state = { - layout: [], - isLayoutInvalid: false, - focusedPanelIndex: undefined, - panels: this.props.container.getInput().panels, - viewMode: this.props.container.getInput().viewMode, - useMargins: this.props.container.getInput().useMargins, - expandedPanelId: this.props.container.getInput().expandedPanelId, - }; - } - - public componentDidMount() { - this.mounted = true; - let isLayoutInvalid = false; - let layout; - - const { - notifications: { toasts }, - } = pluginServices.getServices(); - - try { - layout = this.buildLayoutFromPanels(); - } catch (error) { - console.error(error); // eslint-disable-line no-console - isLayoutInvalid = true; - toasts.addDanger(dashboardSavedObjectErrorStrings.getDashboardGridError(error.message)); - } - this.setState({ - layout, - isLayoutInvalid, - }); - - this.subscription = this.props.container.getInput$().subscribe(() => { - const { panels, viewMode, useMargins, expandedPanelId } = this.props.container.getInput(); - if (this.mounted) { - this.setState({ - panels, - viewMode, - useMargins, - expandedPanelId, - }); - } - }); - } - - public componentWillUnmount() { - this.mounted = false; - if (this.subscription) { - this.subscription.unsubscribe(); - } - } - - public buildLayoutFromPanels = (): GridData[] => { - return _.map(this.state.panels, (panel) => { - return panel.gridData; - }); - }; - - public onLayoutChange = (layout: PanelLayout[]) => { - const panels = this.state.panels; - const updatedPanels: { [key: string]: DashboardPanelState } = layout.reduce( - (updatedPanelsAcc, panelLayout) => { - updatedPanelsAcc[panelLayout.i] = { - ...panels[panelLayout.i], - gridData: _.pick(panelLayout, ['x', 'y', 'w', 'h', 'i']), - }; - return updatedPanelsAcc; - }, - {} as { [key: string]: DashboardPanelState } - ); - this.onPanelsUpdated(updatedPanels); - }; - - public onPanelsUpdated = (panels: { [key: string]: DashboardPanelState }) => { - this.props.container.updateInput({ - panels, - }); - }; - - public onPanelFocused = (focusedPanelIndex: string): void => { - this.setState({ focusedPanelIndex }); - }; - - public onPanelBlurred = (blurredPanelIndex: string): void => { - if (this.state.focusedPanelIndex === blurredPanelIndex) { - this.setState({ focusedPanelIndex: undefined }); - } - }; - - public render() { - if (this.state.isLayoutInvalid) { - return null; - } - - const { container } = this.props; - const { focusedPanelIndex, panels, expandedPanelId, viewMode } = this.state; - const isViewMode = viewMode === ViewMode.VIEW; - - // Part of our unofficial API - need to render in a consistent order for plugins. - const panelsInOrder = Object.keys(panels).map((key: string) => { - return panels[key] as DashboardPanelState; - }); - - panelsInOrder.sort((panelA, panelB) => { - if (panelA.gridData.y === panelB.gridData.y) { - return panelA.gridData.x - panelB.gridData.x; - } else { - return panelA.gridData.y - panelB.gridData.y; - } - }); - - const panelIds: Record> = {}; - const loadStartTime = performance.now(); - let lastTimeToData = 0; - let status: DashboardLoadedEventStatus = 'done'; - let doneCount = 0; - - /** - * Sends an event - * - * @param info - * @returns - */ - const onPanelStatusChange = (info: EmbeddablePhaseEvent) => { - if (!this.props.onDataLoaded) return; - - if (panelIds[info.id] === undefined || info.status === 'loading') { - panelIds[info.id] = {}; - } else if (info.status === 'error') { - status = 'error'; - } else if (info.status === 'loaded') { - lastTimeToData = performance.now(); - } - - panelIds[info.id][info.status] = performance.now(); - - if (info.status === 'error' || info.status === 'rendered') { - doneCount++; - if (doneCount === panelsInOrder.length) { - const doneTime = performance.now(); - const data: DashboardLoadedInfo = { - timeToData: (lastTimeToData || doneTime) - loadStartTime, - timeToDone: doneTime - loadStartTime, - numOfPanels: panelsInOrder.length, - status, - }; - this.props.onDataLoaded(data); - } - } - }; - - const dashboardPanels = _.map(panelsInOrder, ({ explicitInput, type }, index) => ( - - )); - - // in print mode, dashboard layout is not controlled by React Grid Layout - if (viewMode === ViewMode.PRINT) { - return <>{dashboardPanels}; - } - - return ( - - {dashboardPanels} - - ); - } -} - -export const DashboardGrid = injectI18n(DashboardGridUi); diff --git a/src/plugins/dashboard/public/application/embeddable/index.ts b/src/plugins/dashboard/public/application/embeddable/index.ts deleted file mode 100644 index 1979ae5ad7bf..000000000000 --- a/src/plugins/dashboard/public/application/embeddable/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export type { DashboardContainerFactory } from './dashboard_container_factory'; -export { DashboardContainerFactoryDefinition } from './dashboard_container_factory'; -export type { DashboardContainer } from './dashboard_container'; -export { createPanelState } from './panel'; - -export * from './types'; - -export { createDashboardContainerByValueRenderer } from './dashboard_container_by_value_renderer'; diff --git a/src/plugins/dashboard/public/application/embeddable/types.ts b/src/plugins/dashboard/public/application/embeddable/types.ts deleted file mode 100644 index b64fe70f9eb9..000000000000 --- a/src/plugins/dashboard/public/application/embeddable/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export * from '../../../common/types'; - -export type DashboardLoadedEventStatus = 'done' | 'error'; - -export interface DashboardLoadedEventMeta { - status: DashboardLoadedEventStatus; -} diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx deleted file mode 100644 index a7bad078b56e..000000000000 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx +++ /dev/null @@ -1,182 +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 { findTestSubject } from '@elastic/eui/lib/test'; -import React from 'react'; -import { skip } from 'rxjs/operators'; -import { mount } from 'enzyme'; - -import { I18nProvider } from '@kbn/i18n-react'; -import { nextTick } from '@kbn/test-jest-helpers'; -import { - ContactCardEmbeddableFactory, - CONTACT_CARD_EMBEDDABLE, -} from '@kbn/embeddable-plugin/public/lib/test_samples'; - -import { DashboardViewport, DashboardViewportProps } from './dashboard_viewport'; -import { DashboardContainer } from '../dashboard_container'; -import { getSampleDashboardInput } from '../../test_helpers'; -import { pluginServices } from '../../../services/plugin_services'; - -let dashboardContainer: DashboardContainer | undefined; -const DashboardServicesProvider = pluginServices.getContextProvider(); - -function getProps(props?: Partial): { - props: DashboardViewportProps; -} { - const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); - pluginServices.getServices().embeddable.getEmbeddableFactory = jest - .fn() - .mockReturnValue(embeddableFactory); - - const input = getSampleDashboardInput({ - 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' }, - }, - }, - }); - - dashboardContainer = new DashboardContainer(input); - const defaultTestProps: DashboardViewportProps = { - container: dashboardContainer, - }; - - return { - props: Object.assign(defaultTestProps, props), - }; -} -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -test.skip('renders DashboardViewport', () => { - const { props } = getProps(); - const component = mount( - - - - - - ); - const panels = findTestSubject(component, 'dashboardPanel'); - expect(panels.length).toBe(2); -}); - -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -test.skip('renders DashboardViewport with no visualizations', () => { - const { props } = getProps(); - props.container.updateInput({ panels: {} }); - const component = mount( - - - - - - ); - const panels = findTestSubject(component, 'dashboardPanel'); - expect(panels.length).toBe(0); - - component.unmount(); -}); - -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -test.skip('renders DashboardEmptyScreen', () => { - const { props } = getProps(); - props.container.updateInput({ panels: {} }); - const component = mount( - - - - - - ); - const dashboardEmptyScreenDiv = component.find('.dshDashboardEmptyScreen'); - expect(dashboardEmptyScreenDiv.length).toBe(1); - - component.unmount(); -}); - -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -test.skip('renders exit full screen button when in full screen mode', async () => { - const { props } = getProps(); - props.container.updateInput({ isFullScreenMode: true }); - const component = mount( - - - - - - ); - - expect((component.find('.dshDashboardViewport').childAt(0).type() as any).name).toBe( - 'ExitFullScreenButton' - ); - - props.container.updateInput({ isFullScreenMode: false }); - component.update(); - await nextTick(); - - expect((component.find('.dshDashboardViewport').childAt(0).type() as any).name).not.toBe( - 'ExitFullScreenButton' - ); - - component.unmount(); -}); - -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -test.skip('renders exit full screen button when in full screen mode and empty screen', async () => { - const { props } = getProps(); - props.container.updateInput({ panels: {}, isFullScreenMode: true }); - const component = mount( - - - - - - ); - expect((component.find('.dshDashboardViewport').childAt(0).type() as any).name).toBe( - 'ExitFullScreenButton' - ); - - props.container.updateInput({ isFullScreenMode: false }); - component.update(); - await nextTick(); - - expect((component.find('.dshDashboardViewport').childAt(0).type() as any).name).not.toBe( - 'ExitFullScreenButton' - ); - - component.unmount(); -}); - -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -test.skip('DashboardViewport unmount unsubscribes', (done) => { - const { props } = getProps(); - const component = mount( - - - - - - ); - component.unmount(); - - props.container - .getInput$() - .pipe(skip(1)) - .subscribe(() => { - done(); - }); - - props.container.updateInput({ panels: {} }); -}); diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx deleted file mode 100644 index cf2d662aab42..000000000000 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { Subscription } from 'rxjs'; - -import { - CalloutProps, - ControlGroupContainer, - LazyControlsCallout, -} from '@kbn/controls-plugin/public'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { withSuspense } from '@kbn/presentation-util-plugin/public'; -import { context } from '@kbn/kibana-react-plugin/public'; -import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen'; - -import { DashboardContainer, DashboardLoadedInfo } from '../dashboard_container'; -import { DashboardGrid } from '../grid'; -import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen'; -import { pluginServices } from '../../../services/plugin_services'; - -export interface DashboardViewportProps { - container: DashboardContainer; - controlGroup?: ControlGroupContainer; - onDataLoaded?: (data: DashboardLoadedInfo) => void; -} - -interface State { - isFullScreenMode: boolean; - useMargins: boolean; - title: string; - description?: string; - panelCount: number; - isEmbeddedExternally?: boolean; -} - -const ControlsCallout = withSuspense(LazyControlsCallout); - -export class DashboardViewport extends React.Component { - static contextType = context; - private controlsRoot: React.RefObject; - - private subscription?: Subscription; - private mounted: boolean = false; - constructor(props: DashboardViewportProps) { - super(props); - const { isFullScreenMode, panels, useMargins, title, isEmbeddedExternally } = - this.props.container.getInput(); - - this.controlsRoot = React.createRef(); - - this.state = { - isFullScreenMode, - panelCount: Object.values(panels).length, - useMargins, - title, - isEmbeddedExternally, - }; - } - - public componentDidMount() { - this.mounted = true; - this.subscription = this.props.container.getInput$().subscribe(() => { - const { isFullScreenMode, useMargins, title, description, isEmbeddedExternally, panels } = - this.props.container.getInput(); - if (this.mounted) { - this.setState({ - panelCount: Object.values(panels).length, - isEmbeddedExternally, - isFullScreenMode, - description, - useMargins, - title, - }); - } - }); - if (this.props.controlGroup && this.controlsRoot.current) { - this.props.controlGroup.render(this.controlsRoot.current); - } - } - - public componentWillUnmount() { - this.mounted = false; - if (this.subscription) { - this.subscription.unsubscribe(); - } - } - - public onExitFullScreenMode = () => { - this.props.container.updateInput({ - isFullScreenMode: false, - }); - }; - - public render() { - const { container, controlGroup } = this.props; - const isEditMode = container.getInput().viewMode !== ViewMode.VIEW; - const { isEmbeddedExternally, isFullScreenMode, panelCount, title, description, useMargins } = - this.state; - - const { - settings: { isProjectEnabledInLabs, uiSettings }, - } = pluginServices.getServices(); - const controlsEnabled = isProjectEnabledInLabs('labs:dashboard:dashboardControls'); - - const hideAnnouncements = Boolean(uiSettings.get('hideAnnouncements')); - - return ( - <> - {controlsEnabled ? ( - <> - {!hideAnnouncements && - isEditMode && - panelCount !== 0 && - controlGroup?.getPanelCount() === 0 ? ( - { - return controlGroup?.getCreateControlButton('callout'); - }} - /> - ) : null} - - {container.getInput().viewMode !== ViewMode.PRINT && ( -
0 - ? 'dshDashboardViewport-controls' - : '' - } - ref={this.controlsRoot} - /> - )} - - ) : null} -
- {isFullScreenMode && ( - - )} - {this.props.container.getPanelCount() === 0 && ( -
- -
- )} - -
- - ); - } -} diff --git a/src/plugins/dashboard/public/application/hooks/index.ts b/src/plugins/dashboard/public/application/hooks/index.ts deleted file mode 100644 index d9c3cd231c3c..000000000000 --- a/src/plugins/dashboard/public/application/hooks/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { useDashboardAppState } from './use_dashboard_app_state'; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx deleted file mode 100644 index 76a3ae7a053a..000000000000 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx +++ /dev/null @@ -1,292 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { Provider } from 'react-redux'; -import { createBrowserHistory } from 'history'; - -import type { Filter } from '@kbn/es-query'; -import { DataView } from '@kbn/data-views-plugin/public'; -import { EmbeddableFactory, ViewMode } from '@kbn/embeddable-plugin/public'; -import { renderHook, act, RenderHookResult } from '@testing-library/react-hooks'; -import { createKbnUrlStateStorage, defer } from '@kbn/kibana-utils-plugin/public'; - -import { DashboardAppState } from '../../types'; -import { getSampleDashboardInput } from '../test_helpers'; -import { DashboardConstants } from '../../dashboard_constants'; -import { pluginServices } from '../../services/plugin_services'; -import { DashboardContainer } from '../embeddable/dashboard_container'; -import { dashboardStateStore, setDescription, setViewMode } from '../state'; -import { useDashboardAppState, UseDashboardStateProps } from './use_dashboard_app_state'; - -interface SetupEmbeddableFactoryReturn { - finalizeEmbeddableCreation: () => void; - dashboardContainer: DashboardContainer; - dashboardDestroySpy: jest.SpyInstance; -} - -interface RenderDashboardStateHookReturn { - embeddableFactoryResult: SetupEmbeddableFactoryReturn; - renderHookResult: RenderHookResult, DashboardAppState>; - props: UseDashboardStateProps; -} - -const originalDashboardEmbeddableId = 'originalDashboardEmbeddableId'; - -const createDashboardAppStateProps = (): UseDashboardStateProps => ({ - kbnUrlStateStorage: createKbnUrlStateStorage(), - savedDashboardId: 'testDashboardId', - history: createBrowserHistory(), - isEmbeddedExternally: false, - showNoDataPage: false, - setShowNoDataPage: () => {}, -}); - -const setupEmbeddableFactory = (id: string): SetupEmbeddableFactoryReturn => { - const dashboardContainer = new DashboardContainer({ ...getSampleDashboardInput(), id }); - const deferEmbeddableCreate = defer(); - pluginServices.getServices().embeddable.getEmbeddableFactory = jest.fn().mockImplementation( - () => - ({ - create: () => deferEmbeddableCreate.promise, - } as unknown as EmbeddableFactory) - ); - const dashboardDestroySpy = jest.spyOn(dashboardContainer, 'destroy'); - - return { - dashboardContainer, - dashboardDestroySpy, - finalizeEmbeddableCreation: () => { - act(() => { - deferEmbeddableCreate.resolve(dashboardContainer); - }); - }, - }; -}; - -const renderDashboardAppStateHook = ({ - partialProps, -}: { - partialProps?: Partial; -}): RenderDashboardStateHookReturn => { - const defaultDataView = { id: 'foo', fields: [{ name: 'bar' }] } as DataView; - (pluginServices.getServices().data.dataViews.getDefaultDataView as jest.Mock).mockResolvedValue( - defaultDataView - ); - (pluginServices.getServices().data.dataViews.getDefaultId as jest.Mock).mockResolvedValue( - defaultDataView.id - ); - (pluginServices.getServices().data.query.filterManager.getFilters as jest.Mock).mockReturnValue( - [] - ); - - const props = { ...createDashboardAppStateProps(), ...(partialProps ?? {}) }; - const embeddableFactoryResult = setupEmbeddableFactory(originalDashboardEmbeddableId); - - const renderHookResult = renderHook( - (replaceProps: Partial) => { - return useDashboardAppState({ ...props, ...replaceProps }); - }, - { - wrapper: ({ children }) => { - return {children}; - }, - } - ); - return { embeddableFactoryResult, renderHookResult, props }; -}; - -describe('Dashboard container lifecycle', () => { - test('Dashboard container is destroyed on unmount', async () => { - const { renderHookResult, embeddableFactoryResult } = renderDashboardAppStateHook({}); - - embeddableFactoryResult.finalizeEmbeddableCreation(); - await renderHookResult.waitForNextUpdate(); - - expect(embeddableFactoryResult.dashboardContainer).toBe( - renderHookResult.result.current.dashboardContainer - ); - expect(embeddableFactoryResult.dashboardDestroySpy).not.toBeCalled(); - renderHookResult.unmount(); - expect(embeddableFactoryResult.dashboardDestroySpy).toBeCalled(); - }); - - test('Old dashboard container is destroyed when new dashboardId is given', async () => { - const { renderHookResult, embeddableFactoryResult } = renderDashboardAppStateHook({}); - const getResult = () => renderHookResult.result.current; - - // on initial render dashboard container is undefined - expect(getResult().dashboardContainer).toBeUndefined(); - embeddableFactoryResult.finalizeEmbeddableCreation(); - - await renderHookResult.waitForNextUpdate(); - expect(embeddableFactoryResult.dashboardContainer).toBe(getResult().dashboardContainer); - expect(embeddableFactoryResult.dashboardDestroySpy).not.toBeCalled(); - - const newDashboardId = 'wow_a_new_dashboard_id'; - const embeddableFactoryNew = setupEmbeddableFactory(newDashboardId); - renderHookResult.rerender({ savedDashboardId: newDashboardId }); - - embeddableFactoryNew.finalizeEmbeddableCreation(); - await renderHookResult.waitForNextUpdate(); - - expect(embeddableFactoryNew.dashboardContainer).toEqual(getResult().dashboardContainer); - expect(embeddableFactoryNew.dashboardDestroySpy).not.toBeCalled(); - expect(embeddableFactoryResult.dashboardDestroySpy).toBeCalled(); - }); - - test('Dashboard container is destroyed if dashboard id is changed before container is resolved', async () => { - const { renderHookResult, embeddableFactoryResult } = renderDashboardAppStateHook({}); - const getResult = () => renderHookResult.result.current; - - // on initial render dashboard container is undefined - expect(getResult().dashboardContainer).toBeUndefined(); - await act(() => Promise.resolve()); // wait for the original savedDashboard to be loaded... - - const newDashboardId = 'wow_a_new_dashboard_id'; - const embeddableFactoryNew = setupEmbeddableFactory(newDashboardId); - - renderHookResult.rerender({ savedDashboardId: newDashboardId }); - await act(() => Promise.resolve()); // wait for the new savedDashboard to be loaded... - embeddableFactoryNew.finalizeEmbeddableCreation(); - await renderHookResult.waitForNextUpdate(); - expect(embeddableFactoryNew.dashboardContainer).toBe(getResult().dashboardContainer); - expect(embeddableFactoryNew.dashboardDestroySpy).not.toBeCalled(); - - embeddableFactoryResult.finalizeEmbeddableCreation(); - await act(() => Promise.resolve()); // Can't use waitFor from hooks, because there is no hook update - expect(embeddableFactoryNew.dashboardContainer).toBe(getResult().dashboardContainer); - expect(embeddableFactoryNew.dashboardDestroySpy).not.toBeCalled(); - expect(embeddableFactoryResult.dashboardDestroySpy).toBeCalled(); - }); -}); - -// FLAKY: https://github.com/elastic/kibana/issues/116050 -// FLAKY: https://github.com/elastic/kibana/issues/105018 -describe.skip('Dashboard initial state', () => { - it('Extracts state from Dashboard Saved Object', async () => { - const savedTitle = 'testDash1'; - ( - pluginServices.getServices().dashboardSavedObject - .loadDashboardStateFromSavedObject as jest.Mock - ).mockResolvedValue({ title: savedTitle }); - - const { renderHookResult, embeddableFactoryResult } = renderDashboardAppStateHook({}); - const getResult = () => renderHookResult.result.current; - - embeddableFactoryResult.finalizeEmbeddableCreation(); - await renderHookResult.waitForNextUpdate(); - - expect(savedTitle).toEqual(getResult().getLatestDashboardState?.().title); - }); - - it('Sets initial time range and filters from saved dashboard', async () => { - ( - pluginServices.getServices().dashboardSavedObject - .loadDashboardStateFromSavedObject as jest.Mock - ).mockResolvedValue({ - filters: [{ meta: { test: 'filterMeTimbers' } } as unknown as Filter], - timeRestore: true, - timeFrom: 'now-13d', - timeTo: 'now', - }); - - const { renderHookResult, embeddableFactoryResult } = renderDashboardAppStateHook({}); - const getResult = () => renderHookResult.result.current; - - embeddableFactoryResult.finalizeEmbeddableCreation(); - await renderHookResult.waitForNextUpdate(); - - expect(getResult().getLatestDashboardState?.().timeRestore).toEqual(true); - expect( - pluginServices.getServices().data.query.timefilter.timefilter.setTime - ).toHaveBeenCalledWith({ - from: 'now-13d', - to: 'now', - }); - expect( - pluginServices.getServices().data.query.filterManager.setAppFilters - ).toHaveBeenCalledWith([{ meta: { test: 'filterMeTimbers' } } as unknown as Filter]); - }); - - it('Combines session state and URL state into initial state', async () => { - pluginServices.getServices().dashboardSessionStorage.getState = jest - .fn() - .mockReturnValue({ viewMode: ViewMode.EDIT, description: 'this should be overwritten' }); - - const kbnUrlStateStorage = createKbnUrlStateStorage(); - kbnUrlStateStorage.set('_a', { description: 'with this' }); - const { renderHookResult, embeddableFactoryResult } = renderDashboardAppStateHook({ - partialProps: { kbnUrlStateStorage }, - }); - const getResult = () => renderHookResult.result.current; - - embeddableFactoryResult.finalizeEmbeddableCreation(); - await renderHookResult.waitForNextUpdate(); - expect(getResult().getLatestDashboardState?.().description).toEqual('with this'); - expect(getResult().getLatestDashboardState?.().viewMode).toEqual(ViewMode.EDIT); - }); -}); - -// FLAKY: https://github.com/elastic/kibana/issues/116043 -describe.skip('Dashboard state sync', () => { - let defaultDashboardAppStateHookResult: RenderDashboardStateHookReturn; - const getResult = () => defaultDashboardAppStateHookResult.renderHookResult.result.current; - - beforeEach(async () => { - DashboardConstants.CHANGE_APPLY_DEBOUNCE = 0; - DashboardConstants.CHANGE_CHECK_DEBOUNCE = 0; - defaultDashboardAppStateHookResult = renderDashboardAppStateHook({}); - defaultDashboardAppStateHookResult.embeddableFactoryResult.finalizeEmbeddableCreation(); - await defaultDashboardAppStateHookResult.renderHookResult.waitForNextUpdate(); - }); - - it('Updates Dashboard container input when state changes', async () => { - const { embeddableFactoryResult } = defaultDashboardAppStateHookResult; - embeddableFactoryResult.dashboardContainer.updateInput = jest.fn(); - act(() => { - dashboardStateStore.dispatch(setDescription('Well hello there new description')); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3)); // So that $triggerDashboardRefresh.next is called - }); - expect(embeddableFactoryResult.dashboardContainer.updateInput).toHaveBeenCalledWith( - expect.objectContaining({ description: 'Well hello there new description' }) - ); - }); - - it('Updates state when dashboard container input changes', async () => { - const { embeddableFactoryResult } = defaultDashboardAppStateHookResult; - expect(getResult().getLatestDashboardState?.().fullScreenMode).toBe(false); - act(() => { - embeddableFactoryResult.dashboardContainer.updateInput({ - isFullScreenMode: true, - }); - }); - await act(() => Promise.resolve()); - expect(getResult().getLatestDashboardState?.().fullScreenMode).toBe(true); - }); - - it('pushes unsaved changes to the session storage', async () => { - expect(getResult().getLatestDashboardState?.().fullScreenMode).toBe(false); - act(() => { - dashboardStateStore.dispatch(setViewMode(ViewMode.EDIT)); // session storage is only populated in edit mode - dashboardStateStore.dispatch(setDescription('Wow an even cooler description.')); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3)); - }); - expect(pluginServices.getServices().dashboardSessionStorage.setState).toHaveBeenCalledWith( - 'testDashboardId', - expect.objectContaining({ - description: 'Wow an even cooler description.', - viewMode: ViewMode.EDIT, - }) - ); - }); -}); diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts deleted file mode 100644 index 6095598ae378..000000000000 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts +++ /dev/null @@ -1,401 +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 { omit } from 'lodash'; -import { History } from 'history'; -import { debounceTime, switchMap } from 'rxjs/operators'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs'; - -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import type { DataView } from '@kbn/data-plugin/common'; -import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; - -import { - diffDashboardState, - syncDashboardUrlState, - syncDashboardDataViews, - buildDashboardContainer, - syncDashboardFilterState, - syncDashboardContainerInput, - tryDestroyDashboardContainer, - loadDashboardHistoryLocationState, -} from '../lib'; -import { - dashboardStateLoadWasSuccessful, - LoadDashboardFromSavedObjectReturn, -} from '../../services/dashboard_saved_object/lib/load_dashboard_state_from_saved_object'; -import { DashboardConstants } from '../..'; -import { DashboardAppLocatorParams } from '../../locator'; -import { dashboardSavedObjectErrorStrings, getNewDashboardTitle } from '../../dashboard_strings'; -import { pluginServices } from '../../services/plugin_services'; -import { useDashboardMountContext } from './dashboard_mount_context'; -import { isDashboardAppInNoDataState } from '../dashboard_app_no_data'; -import { setDashboardState, useDashboardDispatch, useDashboardSelector } from '../state'; -import type { DashboardBuildContext, DashboardAppState, DashboardState } from '../../types'; - -export interface UseDashboardStateProps { - history: History; - showNoDataPage: boolean; - savedDashboardId?: string; - isEmbeddedExternally: boolean; - kbnUrlStateStorage: IKbnUrlStateStorage; - setShowNoDataPage: (showNoData: boolean) => void; -} - -export const useDashboardAppState = ({ - history, - savedDashboardId, - showNoDataPage, - setShowNoDataPage, - kbnUrlStateStorage, - isEmbeddedExternally, -}: UseDashboardStateProps) => { - const dispatchDashboardStateChange = useDashboardDispatch(); - const dashboardState = useDashboardSelector((state) => state.dashboardStateReducer); - - /** - * Dashboard app state is the return value for this hook and contains interaction points that the rest of the app can use - * to read or manipulate dashboard state. - */ - const [dashboardAppState, setDashboardAppState] = useState(() => ({ - $onDashboardStateChange: new BehaviorSubject({} as DashboardState), - $triggerDashboardRefresh: new Subject<{ force?: boolean }>(), - })); - - /** - * Last saved state is diffed against the current dashboard state any time either changes. This is used to set the - * unsaved changes portion of the dashboardAppState. - */ - const [lastSavedState, setLastSavedState] = useState(); - const $onLastSavedStateChange = useMemo(() => new Subject(), []); - - /** - * Unpack services and context - */ - const { scopedHistory } = useDashboardMountContext(); - const { - embeddable, - notifications: { toasts }, - chrome: { docTitle }, - dashboardCapabilities, - dashboardSessionStorage, - spaces: { redirectLegacyUrl }, - data: { query, search, dataViews }, - initializerContext: { kibanaVersion }, - screenshotMode: { isScreenshotMode, getScreenshotContext }, - dashboardSavedObject: { loadDashboardStateFromSavedObject }, - } = pluginServices.getServices(); - - const { getStateTransfer } = embeddable; - - /** - * This useEffect triggers when the dashboard ID changes, and is in charge of loading the saved dashboard, - * fetching the initial state, building the Dashboard Container embeddable, and setting up all state syncing. - */ - useEffect(() => { - // fetch incoming embeddable from state transfer service. - const incomingEmbeddable = getStateTransfer().getIncomingEmbeddablePackage( - DashboardConstants.DASHBOARDS_ID, - true - ); - - let canceled = false; - let onDestroy: () => void; - - /** - * The dashboard build context is a collection of all of the services and props required in subsequent steps to build the dashboard - * from the dashboardId. This build context doesn't contain any extrenuous services. - */ - const dashboardBuildContext: DashboardBuildContext = { - history, - kbnUrlStateStorage, - isEmbeddedExternally, - dispatchDashboardStateChange, - $checkForUnsavedChanges: new Subject(), - $onDashboardStateChange: dashboardAppState.$onDashboardStateChange, - $triggerDashboardRefresh: dashboardAppState.$triggerDashboardRefresh, - getLatestDashboardState: () => dashboardAppState.$onDashboardStateChange.value, - }; - - (async () => { - /** - * Ensure default data view exists and there is data in elasticsearch - */ - const isEmpty = await isDashboardAppInNoDataState(); - if (showNoDataPage || isEmpty) { - setShowNoDataPage(true); - return; - } - - const defaultDataView = await dataViews.getDefaultDataView(); - - if (!defaultDataView) { - return; - } - - /** - * Load and unpack state from dashboard saved object. - */ - let loadSavedDashboardResult: LoadDashboardFromSavedObjectReturn; - try { - loadSavedDashboardResult = await loadDashboardStateFromSavedObject({ - getScopedHistory: scopedHistory, - id: savedDashboardId, - }); - } catch (error) { - // redirect back to landing page if dashboard could not be loaded. - toasts.addDanger(dashboardSavedObjectErrorStrings.getDashboardLoadError(error.message)); - history.push(DashboardConstants.LANDING_PAGE_PATH); - return; - } - if (canceled || !dashboardStateLoadWasSuccessful(loadSavedDashboardResult)) { - return; - } - - const { dashboardState: savedDashboardState, createConflictWarning } = - loadSavedDashboardResult; - - /** - * Combine initial state from the saved object, session storage, and URL, then dispatch it to Redux. - */ - const dashboardSessionStorageState = dashboardSessionStorage.getState(savedDashboardId) || {}; - - const forwardedAppState = loadDashboardHistoryLocationState( - scopedHistory()?.location?.state as undefined | DashboardAppLocatorParams - ); - - const { initialDashboardStateFromUrl, stopWatchingAppStateInUrl } = syncDashboardUrlState({ - ...dashboardBuildContext, - }); - - const printLayoutDetected = isScreenshotMode() && getScreenshotContext('layout') === 'print'; - - const initialDashboardState: DashboardState = { - ...savedDashboardState, - ...dashboardSessionStorageState, - ...initialDashboardStateFromUrl, - ...forwardedAppState, - - ...(printLayoutDetected ? { viewMode: ViewMode.PRINT } : {}), - - // if there is an incoming embeddable, dashboard always needs to be in edit mode to receive it. - ...(incomingEmbeddable ? { viewMode: ViewMode.EDIT } : {}), - }; - dispatchDashboardStateChange(setDashboardState(initialDashboardState)); - - /** - * Start syncing dashboard state with the Query, Filters and Timepicker from the Query Service. - */ - const { stopSyncingDashboardFilterState } = syncDashboardFilterState({ - ...dashboardBuildContext, - initialDashboardState, - }); - - /** - * Build the dashboard container embeddable, and apply the incoming embeddable if it exists. - */ - - const dashboardContainer = await buildDashboardContainer({ - ...dashboardBuildContext, - initialDashboardState, - incomingEmbeddable, - executionContext: { - type: 'dashboard', - description: initialDashboardState.title, - }, - }); - - if (canceled || !dashboardContainer) { - tryDestroyDashboardContainer(dashboardContainer); - return; - } - - /** - * Start syncing index patterns between the Query Service and the Dashboard Container. - */ - const dataViewsSubscription = syncDashboardDataViews({ - dashboardContainer, - onUpdateDataViews: async (newDataViewIds: string[]) => { - if (newDataViewIds?.[0]) { - dashboardContainer.controlGroup?.setRelevantDataViewId(newDataViewIds[0]); - } - // fetch all data views. These should be cached locally at this time so we will not need to query ES. - const responses = await Promise.allSettled(newDataViewIds.map((id) => dataViews.get(id))); - // Keep only fullfilled ones as each panel will handle the rejected ones already on their own - const allDataViews = responses - .filter( - (response): response is PromiseFulfilledResult => - response.status === 'fulfilled' - ) - .map(({ value }) => value); - dashboardContainer.setAllDataViews(allDataViews); - setDashboardAppState((s) => ({ ...s, dataViews: allDataViews })); - }, - }); - - /** - * Set up the two way syncing between the Dashboard Container and the Redux Store. - */ - const stopSyncingContainerInput = syncDashboardContainerInput({ - ...dashboardBuildContext, - dashboardContainer, - }); - - /** - * Any time the redux state, or the last saved state changes, compare them, set the unsaved - * changes state, and and push the unsaved changes to session storage. - */ - - const lastSavedSubscription = combineLatest([ - $onLastSavedStateChange, - dashboardAppState.$onDashboardStateChange, - dashboardBuildContext.$checkForUnsavedChanges, - ]) - .pipe( - debounceTime(DashboardConstants.CHANGE_CHECK_DEBOUNCE), - switchMap((states) => { - return new Observable((observer) => { - const [lastSaved, current] = states; - diffDashboardState({ - getEmbeddable: (id: string) => dashboardContainer.untilEmbeddableLoaded(id), - originalState: lastSaved, - newState: current, - }).then((unsavedChanges) => { - if (observer.closed) return; - /** - * changes to the dashboard should only be considered 'unsaved changes' when - * editing the dashboard - */ - const hasUnsavedChanges = - current.viewMode === ViewMode.EDIT && Object.keys(unsavedChanges).length > 0; - setDashboardAppState((s) => ({ ...s, hasUnsavedChanges })); - - unsavedChanges.viewMode = current.viewMode; // always push view mode into session store. - - /** - * Current behaviour expects time range not to be backed up. - * TODO: Revisit this. It seems like we should treat all state the same. - */ - dashboardSessionStorage.setState( - savedDashboardId, - omit(unsavedChanges, ['timeRange', 'refreshInterval']) - ); - }); - }); - }) - ) - .subscribe(); - - /** - * initialize the last saved state, and build a callback which can be used to update - * the last saved state on save. - */ - setLastSavedState(savedDashboardState); - dashboardBuildContext.$checkForUnsavedChanges.next(undefined); - const updateLastSavedState = () => { - setLastSavedState(dashboardBuildContext.getLatestDashboardState()); - }; - - /** - * Apply changes to the dashboard app state, and set the document title - */ - docTitle.change(savedDashboardState.title || getNewDashboardTitle()); - setDashboardAppState((s) => ({ - ...s, - dashboardContainer, - updateLastSavedState, - createConflictWarning, - getLatestDashboardState: dashboardBuildContext.getLatestDashboardState, - })); - - onDestroy = () => { - stopSyncingContainerInput(); - stopWatchingAppStateInUrl(); - stopSyncingDashboardFilterState(); - lastSavedSubscription.unsubscribe(); - dataViewsSubscription.unsubscribe(); - tryDestroyDashboardContainer(dashboardContainer); - setDashboardAppState((state) => ({ - ...state, - dashboardContainer: undefined, - })); - }; - })(); - return () => { - canceled = true; - onDestroy?.(); - }; - }, [ - dashboardAppState.$triggerDashboardRefresh, - dashboardAppState.$onDashboardStateChange, - loadDashboardStateFromSavedObject, - dispatchDashboardStateChange, - $onLastSavedStateChange, - dashboardSessionStorage, - dashboardCapabilities, - isEmbeddedExternally, - getScreenshotContext, - kbnUrlStateStorage, - setShowNoDataPage, - redirectLegacyUrl, - savedDashboardId, - isScreenshotMode, - getStateTransfer, - showNoDataPage, - scopedHistory, - kibanaVersion, - dataViews, - embeddable, - docTitle, - history, - toasts, - search, - query, - ]); - - /** - * rebuild reset to last saved state callback whenever last saved state changes - */ - const resetToLastSavedState = useCallback(() => { - if (!lastSavedState || !dashboardAppState.getLatestDashboardState) { - return; - } - - if (dashboardAppState.getLatestDashboardState().timeRestore) { - const { timefilter } = query.timefilter; - const { timeRange, refreshInterval } = lastSavedState; - if (timeRange) timefilter.setTime(timeRange); - if (refreshInterval) timefilter.setRefreshInterval(refreshInterval); - } - dispatchDashboardStateChange( - setDashboardState({ - ...lastSavedState, - viewMode: ViewMode.VIEW, - }) - ); - }, [lastSavedState, dashboardAppState, query.timefilter, dispatchDashboardStateChange]); - - /** - * publish state to the state change observable when redux state changes - */ - useEffect(() => { - if (!dashboardState || Object.keys(dashboardState).length === 0) return; - dashboardAppState.$onDashboardStateChange.next(dashboardState); - }, [dashboardAppState.$onDashboardStateChange, dashboardState]); - - /** - * push last saved state to the state change observable when last saved state changes - */ - useEffect(() => { - if (!lastSavedState) return; - $onLastSavedStateChange.next(lastSavedState); - }, [$onLastSavedStateChange, lastSavedState]); - - return { ...dashboardAppState, resetToLastSavedState }; -}; diff --git a/src/plugins/dashboard/public/application/index.scss b/src/plugins/dashboard/public/application/index.scss deleted file mode 100644 index d76e022c97cc..000000000000 --- a/src/plugins/dashboard/public/application/index.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import '../../../embeddable/public/variables'; - -@import './embeddable/grid/index'; -@import './embeddable/panel/index'; -@import './embeddable/viewport/index'; - -// Prefix all styles with "dsh" to avoid conflicts. -// Examples -// dshChart -// dshChart__legend -// dshChart__legend--small -// dshChart__legend-isLoading - -@import './dashboard_app'; diff --git a/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts b/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts deleted file mode 100644 index f71b19da6fd3..000000000000 --- a/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import type { KibanaExecutionContext } from '@kbn/core/public'; -import { - ContainerOutput, - EmbeddableFactoryNotFoundError, - EmbeddableInput, - EmbeddablePackageState, - ErrorEmbeddable, - isErrorEmbeddable, -} from '@kbn/embeddable-plugin/public'; - -import { DashboardContainer } from '../embeddable'; -import { DashboardBuildContext, DashboardState, DashboardContainerInput } from '../../types'; -import { - enableDashboardSearchSessions, - getSearchSessionIdFromURL, - stateToDashboardContainerInput, -} from '.'; -import { pluginServices } from '../../services/plugin_services'; -import { DASHBOARD_CONTAINER_TYPE } from '../../dashboard_constants'; - -type BuildDashboardContainerProps = DashboardBuildContext & { - initialDashboardState: DashboardState; - incomingEmbeddable?: EmbeddablePackageState; - executionContext?: KibanaExecutionContext; -}; - -/** - * Builds the dashboard container and manages initial search session - */ -export const buildDashboardContainer = async ({ - getLatestDashboardState, - initialDashboardState, - isEmbeddedExternally, - incomingEmbeddable, - history, - executionContext, -}: BuildDashboardContainerProps) => { - const { - dashboardCapabilities: { storeSearchSession: canStoreSearchSession }, - data: { - search: { session }, - }, - embeddable: { getEmbeddableFactory }, - } = pluginServices.getServices(); - - // set up search session - enableDashboardSearchSessions({ - initialDashboardState, - getLatestDashboardState, - canStoreSearchSession, - }); - - if (incomingEmbeddable?.searchSessionId) { - session.continue(incomingEmbeddable?.searchSessionId); - } - - const searchSessionIdFromURL = getSearchSessionIdFromURL(history); - if (searchSessionIdFromURL) { - session.restore(searchSessionIdFromURL); - } - - const dashboardFactory = getEmbeddableFactory< - DashboardContainerInput, - ContainerOutput, - DashboardContainer - >(DASHBOARD_CONTAINER_TYPE); - - if (!dashboardFactory) { - throw new EmbeddableFactoryNotFoundError('dashboard app requires dashboard embeddable factory'); - } - - /** - * Use an existing session instead of starting a new one if there is a session already, and dashboard is being created with an incoming - * embeddable. - */ - const existingSession = session.getSessionId(); - const searchSessionId = - searchSessionIdFromURL ?? - (existingSession && incomingEmbeddable ? existingSession : session.start()); - - // Build the initial input for the dashboard container based on the dashboard state. - const initialInput = stateToDashboardContainerInput({ - isEmbeddedExternally: Boolean(isEmbeddedExternally), - dashboardState: initialDashboardState, - incomingEmbeddable, - searchSessionId, - executionContext, - }); - - /** - * Handle the Incoming Embeddable Part 1: - * If the incoming embeddable already exists e.g. if it has been edited by value, the incoming state for that panel needs to replace the - * state for the matching panel already in the dashboard. This needs to happen BEFORE the dashboard container is built, so that the panel - * retains the same placement. - */ - if (incomingEmbeddable?.embeddableId && initialInput.panels[incomingEmbeddable.embeddableId]) { - const originalPanelState = initialInput.panels[incomingEmbeddable.embeddableId]; - initialInput.panels = { - ...initialInput.panels, - [incomingEmbeddable.embeddableId]: { - gridData: originalPanelState.gridData, - type: incomingEmbeddable.type, - explicitInput: { - // even when we change embeddable type we should keep hidePanelTitles state - // this is temporary, and only required because the key is stored in explicitInput - // when it should be stored outside of it instead. - ...(incomingEmbeddable.type === originalPanelState.type - ? { - ...originalPanelState.explicitInput, - } - : { hidePanelTitles: originalPanelState.explicitInput.hidePanelTitles }), - ...incomingEmbeddable.input, - id: incomingEmbeddable.embeddableId, - }, - }, - }; - } - - const dashboardContainer = await dashboardFactory.create(initialInput); - if (!dashboardContainer || isErrorEmbeddable(dashboardContainer)) { - tryDestroyDashboardContainer(dashboardContainer); - return; - } - - /** - * Handle the Incoming Embeddable Part 2: - * If the incoming embeddable is new, we can add it to the container using `addNewEmbeddable` after the container is created - * this lets the container handle the placement of it (using the default placement algorithm "top left most open space") - */ - if ( - incomingEmbeddable && - (!incomingEmbeddable?.embeddableId || - (incomingEmbeddable.embeddableId && - !dashboardContainer.getInput().panels[incomingEmbeddable.embeddableId])) - ) { - dashboardContainer.addNewEmbeddable( - incomingEmbeddable.type, - incomingEmbeddable.input - ); - } - - return dashboardContainer; -}; - -export const tryDestroyDashboardContainer = ( - container: DashboardContainer | ErrorEmbeddable | undefined -) => { - try { - container?.destroy(); - } catch (e) { - // destroy could throw if something has already destroyed the container - // eslint-disable-next-line no-console - console.warn(e); - } -}; diff --git a/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts deleted file mode 100644 index fd40aec20be6..000000000000 --- a/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts +++ /dev/null @@ -1,116 +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 { cloneDeep } from 'lodash'; - -import type { KibanaExecutionContext } from '@kbn/core/public'; -import { mapAndFlattenFilters } from '@kbn/data-plugin/public'; -import { type EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; -import { Filter, isFilterPinned, compareFilters, COMPARE_ALL_OPTIONS } from '@kbn/es-query'; - -import { pluginServices } from '../../services/plugin_services'; -import { convertPanelStateToSavedDashboardPanel } from '../../../common'; -import type { DashboardState, RawDashboardState, DashboardContainerInput } from '../../types'; - -interface StateToDashboardContainerInputProps { - searchSessionId?: string; - isEmbeddedExternally?: boolean; - dashboardState: DashboardState; - incomingEmbeddable?: EmbeddablePackageState; - executionContext?: KibanaExecutionContext; -} - -interface StateToRawDashboardStateProps { - state: Partial; -} - -/** - * Converts a dashboard state object to dashboard container input - */ -export const stateToDashboardContainerInput = ({ - isEmbeddedExternally, - searchSessionId, - dashboardState, - executionContext, -}: StateToDashboardContainerInputProps): DashboardContainerInput => { - const { - data: { - query: { filterManager, timefilter: timefilterService }, - }, - } = pluginServices.getServices(); - const { timefilter } = timefilterService; - - const { - controlGroupInput, - expandedPanelId, - fullScreenMode, - description, - options, - viewMode, - panels, - query, - title, - timeRestore, - timeslice, - filters: dashboardFilters, - } = dashboardState; - - const migratedDashboardFilters = mapAndFlattenFilters(cloneDeep(dashboardFilters)); - return { - refreshConfig: timefilter.getRefreshInterval(), - filters: filterManager - .getFilters() - .filter( - (filter) => - isFilterPinned(filter) || - migratedDashboardFilters.some((dashboardFilter) => - filtersAreEqual(dashboardFilter, filter) - ) - ), - isFullScreenMode: fullScreenMode, - id: dashboardState.savedObjectId ?? '', - isEmbeddedExternally, - ...(options || {}), - controlGroupInput, - searchSessionId, - expandedPanelId, - description, - viewMode, - panels, - query, - title, - timeRange: { - ...cloneDeep(timefilter.getTime()), - }, - timeslice, - timeRestore, - executionContext, - }; -}; - -const filtersAreEqual = (first: Filter, second: Filter) => - compareFilters(first, second, { ...COMPARE_ALL_OPTIONS, state: false }); - -/** - * Converts a given dashboard state object to raw dashboard state. This is useful for sharing, and session restoration, as - * they require panels to be formatted as an array. - */ -export const stateToRawDashboardState = ({ - state, -}: StateToRawDashboardStateProps): Partial => { - const { - initializerContext: { kibanaVersion }, - } = pluginServices.getServices(); - - const savedDashboardPanels = state?.panels - ? Object.values(state.panels).map((panel) => - convertPanelStateToSavedDashboardPanel(panel, kibanaVersion) - ) - : undefined; - return { ...state, panels: savedDashboardPanels }; -}; diff --git a/src/plugins/dashboard/public/application/lib/dashboard_session_restoration.test.ts b/src/plugins/dashboard/public/application/lib/dashboard_session_restoration.test.ts deleted file mode 100644 index 56ee2ac55f44..000000000000 --- a/src/plugins/dashboard/public/application/lib/dashboard_session_restoration.test.ts +++ /dev/null @@ -1,51 +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 { DashboardState } from '../../types'; -import { createSessionRestorationDataProvider } from '.'; -import { pluginServices } from '../../services/plugin_services'; - -describe('createSessionRestorationDataProvider', () => { - const searchSessionInfoProvider = createSessionRestorationDataProvider({ - getAppState: () => ({ panels: {} } as unknown as DashboardState), - getDashboardTitle: () => 'Dashboard', - getDashboardId: () => 'Id', - }); - - describe('session state', () => { - test('restoreState has sessionId and initialState has not', async () => { - const searchSessionId = 'id'; - ( - pluginServices.getServices().data.search.session.getSessionId as jest.Mock - ).mockImplementation(() => searchSessionId); - const { initialState, restoreState } = await searchSessionInfoProvider.getLocatorData(); - expect(initialState.searchSessionId).toBeUndefined(); - expect(restoreState.searchSessionId).toBe(searchSessionId); - }); - - test('restoreState has absoluteTimeRange', async () => { - const relativeTime = 'relativeTime'; - const absoluteTime = 'absoluteTime'; - ( - pluginServices.getServices().data.query.timefilter.timefilter.getTime as jest.Mock - ).mockImplementation(() => relativeTime); - ( - pluginServices.getServices().data.query.timefilter.timefilter.getAbsoluteTime as jest.Mock - ).mockImplementation(() => absoluteTime); - const { initialState, restoreState } = await searchSessionInfoProvider.getLocatorData(); - expect(initialState.timeRange).toBe(relativeTime); - expect(restoreState.timeRange).toBe(absoluteTime); - }); - - test('restoreState has refreshInterval paused', async () => { - const { initialState, restoreState } = await searchSessionInfoProvider.getLocatorData(); - expect(initialState.refreshInterval).toBeUndefined(); - expect(restoreState.refreshInterval?.pause).toBe(true); - }); - }); -}); diff --git a/src/plugins/dashboard/public/application/lib/dashboard_session_restoration.ts b/src/plugins/dashboard/public/application/lib/dashboard_session_restoration.ts deleted file mode 100644 index 113c39d0717b..000000000000 --- a/src/plugins/dashboard/public/application/lib/dashboard_session_restoration.ts +++ /dev/null @@ -1,121 +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 { History } from 'history'; - -import { createQueryParamObservable } from '@kbn/kibana-utils-plugin/public'; -import type { Query } from '@kbn/es-query'; -import { - noSearchSessionStorageCapabilityMessage, - SearchSessionInfoProvider, -} from '@kbn/data-plugin/public'; -import { getQueryParams } from '@kbn/kibana-utils-plugin/public'; - -import type { DashboardState } from '../../types'; -import { DASHBOARD_APP_LOCATOR } from '../../locator'; -import { getDashboardTitle } from '../../dashboard_strings'; -import { pluginServices } from '../../services/plugin_services'; -import { DashboardAppLocatorParams, DashboardConstants } from '../..'; -import { stateToRawDashboardState } from './convert_dashboard_state'; - -export const getSearchSessionIdFromURL = (history: History): string | undefined => - getQueryParams(history.location)[DashboardConstants.SEARCH_SESSION_ID] as string | undefined; - -export const getSessionURLObservable = (history: History) => - createQueryParamObservable(history, DashboardConstants.SEARCH_SESSION_ID); - -export function createSessionRestorationDataProvider(deps: { - getAppState: () => DashboardState; - getDashboardTitle: () => string; - getDashboardId: () => string; -}): SearchSessionInfoProvider { - return { - getName: async () => deps.getDashboardTitle(), - getLocatorData: async () => ({ - id: DASHBOARD_APP_LOCATOR, - initialState: getLocatorParams({ ...deps, shouldRestoreSearchSession: false }), - restoreState: getLocatorParams({ ...deps, shouldRestoreSearchSession: true }), - }), - }; -} - -/** - * Enables dashboard search sessions. - */ -export function enableDashboardSearchSessions({ - canStoreSearchSession, - initialDashboardState, - getLatestDashboardState, -}: { - canStoreSearchSession: boolean; - initialDashboardState: DashboardState; - getLatestDashboardState: () => DashboardState; -}) { - const { data } = pluginServices.getServices(); - const dashboardTitle = getDashboardTitle( - initialDashboardState.title, - initialDashboardState.viewMode, - !getLatestDashboardState().savedObjectId - ); - - data.search.session.enableStorage( - createSessionRestorationDataProvider({ - getDashboardTitle: () => dashboardTitle, - getDashboardId: () => getLatestDashboardState().savedObjectId ?? '', - getAppState: getLatestDashboardState, - }), - { - isDisabled: () => - canStoreSearchSession - ? { disabled: false } - : { - disabled: true, - reasonText: noSearchSessionStorageCapabilityMessage, - }, - } - ); -} - -/** - * Fetches the state to store when a session is saved so that this dashboard can be recreated exactly - * as it was. - */ -function getLocatorParams({ - getAppState, - getDashboardId, - shouldRestoreSearchSession, -}: { - getAppState: () => DashboardState; - getDashboardId: () => string; - shouldRestoreSearchSession: boolean; -}): DashboardAppLocatorParams { - const { data } = pluginServices.getServices(); - - const appState = stateToRawDashboardState({ state: getAppState() }); - const { filterManager, queryString } = data.query; - const { timefilter } = data.query.timefilter; - - return { - timeRange: shouldRestoreSearchSession ? timefilter.getAbsoluteTime() : timefilter.getTime(), - searchSessionId: shouldRestoreSearchSession ? data.search.session.getSessionId() : undefined, - panels: getDashboardId() ? undefined : (appState.panels as DashboardAppLocatorParams['panels']), - query: queryString.formatQuery(appState.query) as Query, - filters: filterManager.getFilters(), - savedQuery: appState.savedQuery, - dashboardId: getDashboardId(), - preserveSavedFilters: false, - viewMode: appState.viewMode, - useHash: false, - refreshInterval: shouldRestoreSearchSession - ? { - pause: true, // force pause refresh interval when restoring a session - value: 0, - } - : undefined, - }; -} diff --git a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.test.ts b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.test.ts deleted file mode 100644 index 9bcdbd1bed9a..000000000000 --- a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.test.ts +++ /dev/null @@ -1,172 +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 { Filter } from '@kbn/es-query'; -import { EmbeddableInput, IEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; - -import { DashboardOptions, DashboardState } from '../../types'; -import { diffDashboardState } from './diff_dashboard_state'; - -const testFilter: Filter = { - meta: { - alias: null, - disabled: false, - negate: false, - }, - query: { query: 'hi' }, -}; - -const getEmbeddable = (id: string) => - Promise.resolve({ - getExplicitInputIsEqual: (previousInput: EmbeddableInput) => true, - } as unknown as IEmbeddable); - -const getDashboardState = (state?: Partial): DashboardState => { - const defaultState: DashboardState = { - description: 'This is a dashboard which is very neat', - query: { query: '', language: 'kql' }, - title: 'A very neat dashboard', - viewMode: ViewMode.VIEW, - fullScreenMode: false, - filters: [testFilter], - timeRestore: false, - tags: [], - options: { - hidePanelTitles: false, - useMargins: true, - syncColors: false, - syncTooltips: false, - syncCursor: true, - }, - panels: { - panel_1: { - type: 'panel_type', - gridData: { w: 0, h: 0, x: 0, y: 0, i: 'panel_1' }, - panelRefName: 'panel_panel_1', - explicitInput: { - id: 'panel_1', - }, - }, - panel_2: { - type: 'panel_type', - gridData: { w: 0, h: 0, x: 0, y: 0, i: 'panel_2' }, - panelRefName: 'panel_panel_2', - explicitInput: { - id: 'panel_1', - }, - }, - }, - }; - return { ...defaultState, ...state }; -}; - -const getKeysFromDiff = async (partialState?: Partial): Promise => - Object.keys( - await diffDashboardState({ - originalState: getDashboardState(), - newState: getDashboardState(partialState), - getEmbeddable, - }) - ); - -describe('Dashboard state diff function', () => { - it('finds no difference in equal states', async () => { - expect(await getKeysFromDiff()).toEqual([]); - }); - - it('diffs simple state keys correctly', async () => { - expect( - ( - await getKeysFromDiff({ - timeRestore: true, - title: 'what a cool new title', - description: 'what a cool new description', - query: { query: 'woah a query', language: 'kql' }, - }) - ).sort() - ).toEqual(['description', 'query', 'timeRestore', 'title']); - }); - - it('picks up differences in dashboard options', async () => { - expect( - await getKeysFromDiff({ - options: { - hidePanelTitles: false, - useMargins: false, - syncColors: false, - syncTooltips: false, - syncCursor: true, - }, - }) - ).toEqual(['options']); - }); - - it('considers undefined and false to be equivalent in dashboard options', async () => { - expect( - await getKeysFromDiff({ - options: { - useMargins: true, - syncColors: undefined, - syncTooltips: undefined, - syncCursor: true, - } as unknown as DashboardOptions, - }) - ).toEqual([]); - }); - - it('calls getExplicitInputIsEqual on each panel', async () => { - const mockedGetEmbeddable = jest.fn().mockImplementation((id) => getEmbeddable(id)); - await diffDashboardState({ - originalState: getDashboardState(), - newState: getDashboardState(), - getEmbeddable: mockedGetEmbeddable, - }); - expect(mockedGetEmbeddable).toHaveBeenCalledTimes(2); - }); - - it('short circuits panels comparison when one panel returns false', async () => { - const mockedGetEmbeddable = jest.fn().mockImplementation((id) => { - if (id === 'panel_1') { - return Promise.resolve({ - getExplicitInputIsEqual: (previousInput: EmbeddableInput) => false, - } as unknown as IEmbeddable); - } - getEmbeddable(id); - }); - - await diffDashboardState({ - originalState: getDashboardState(), - newState: getDashboardState(), - getEmbeddable: mockedGetEmbeddable, - }); - expect(mockedGetEmbeddable).toHaveBeenCalledTimes(1); - }); - - it('skips individual panel comparisons if panel ids are different', async () => { - const mockedGetEmbeddable = jest.fn().mockImplementation((id) => getEmbeddable(id)); - const stateDiff = await diffDashboardState({ - originalState: getDashboardState(), - newState: getDashboardState({ - panels: { - panel_1: { - type: 'panel_type', - gridData: { w: 0, h: 0, x: 0, y: 0, i: 'panel_1' }, - panelRefName: 'panel_panel_1', - explicitInput: { - id: 'panel_1', - }, - }, - // panel 2 has been deleted - }, - }), - getEmbeddable: mockedGetEmbeddable, - }); - expect(mockedGetEmbeddable).not.toHaveBeenCalled(); - expect(Object.keys(stateDiff)).toEqual(['panels']); - }); -}); diff --git a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts deleted file mode 100644 index e5432b50550e..000000000000 --- a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts +++ /dev/null @@ -1,245 +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 fastIsEqual from 'fast-deep-equal'; -import { xor, omit, isEmpty, pick } from 'lodash'; - -import { - compareFilters, - COMPARE_ALL_OPTIONS, - type Filter, - isFilterPinned, - TimeRange, -} from '@kbn/es-query'; -import { RefreshInterval } from '@kbn/data-plugin/common'; -import { IEmbeddable } from '@kbn/embeddable-plugin/public'; -import { persistableControlGroupInputIsEqual } from '@kbn/controls-plugin/common'; - -import { DashboardContainerInput } from '../..'; -import { areTimesEqual } from './filter_utils'; -import { DashboardPanelMap } from '../embeddable'; -import { DashboardOptions, DashboardState } from '../../types'; -import { pluginServices } from '../../services/plugin_services'; - -const stateKeystoIgnore = [ - 'expandedPanelId', - 'fullScreenMode', - 'savedQuery', - 'viewMode', - 'tags', - 'timeslice', -]; -type DashboardStateToCompare = Omit; - -const inputKeystoIgnore = ['searchSessionId', 'lastReloadRequestTime', 'executionContext'] as const; -type DashboardInputToCompare = Omit; - -/** - * The diff dashboard Container method is used to sync redux state and the dashboard container input. - * It should eventually be replaced with a usage of the dashboardContainer.isInputEqual function - **/ -export const diffDashboardContainerInput = ( - originalInput: DashboardContainerInput, - newInput: DashboardContainerInput -): Partial => { - const { filters: originalFilters, ...commonOriginal } = omit(originalInput, inputKeystoIgnore); - const { filters: newFilters, ...commonNew } = omit(newInput, inputKeystoIgnore); - - const commonInputDiff: Partial = commonDiff(commonOriginal, commonNew); - const filtersAreEqual = getFiltersAreEqual(originalInput.filters, newInput.filters); - - return { - ...commonInputDiff, - ...(filtersAreEqual ? {} : { filters: newInput.filters }), - }; -}; - -/** - * The diff dashboard state method compares dashboard state keys to determine which state keys - * have changed, and therefore should be backed up. - **/ -export const diffDashboardState = async ({ - originalState, - newState, - getEmbeddable, -}: { - originalState: DashboardState; - newState: DashboardState; - getEmbeddable: (id: string) => Promise; -}): Promise> => { - if (!newState.timeRestore) { - stateKeystoIgnore.push('timeRange'); - } - const { - controlGroupInput: originalControlGroupInput, - options: originalOptions, - filters: originalFilters, - panels: originalPanels, - ...commonCompareOriginal - } = omit(originalState, stateKeystoIgnore); - const { - controlGroupInput: newControlGroupInput, - options: newOptions, - filters: newFilters, - panels: newPanels, - ...commonCompareNew - } = omit(newState, stateKeystoIgnore); - - const commonStateDiff: Partial = commonDiff( - commonCompareOriginal, - commonCompareNew - ); - - const panelsAreEqual = await getPanelsAreEqual( - originalState.panels, - newState.panels, - getEmbeddable - ); - const optionsAreEqual = getOptionsAreEqual(originalState.options, newState.options); - const controlGroupIsEqual = persistableControlGroupInputIsEqual( - originalState.controlGroupInput, - newState.controlGroupInput - ); - - const filterStateDiff = getFiltersAreEqual(originalState.filters, newState.filters, true) - ? {} - : { - filters: newState.filters.filter((f) => !isFilterPinned(f)), - }; - - const timeStatediff = getTimeSettingsAreEqual({ - currentTimeRestore: newState.timeRestore, - lastSaved: { ...pick(originalState, ['timeRange', 'timeRestore', 'refreshInterval']) }, - }) - ? {} - : pick(newState, ['timeRange', 'timeRestore', 'refreshInterval']); - - return { - ...commonStateDiff, - ...(panelsAreEqual ? {} : { panels: newState.panels }), - ...(optionsAreEqual ? {} : { options: newState.options }), - ...(controlGroupIsEqual ? {} : { controlGroupInput: newState.controlGroupInput }), - ...filterStateDiff, - ...timeStatediff, - }; -}; - -interface TimeStateDiffArg { - timeRange?: TimeRange; - timeRestore?: boolean; - refreshInterval?: RefreshInterval; -} - -export const getTimeSettingsAreEqual = ({ - lastSaved, - currentTimeRestore, -}: { - lastSaved?: TimeStateDiffArg; - currentTimeRestore?: boolean; -}) => { - const { - data: { - query: { - timefilter: { timefilter }, - }, - }, - } = pluginServices.getServices(); - - if (currentTimeRestore !== lastSaved?.timeRestore) return false; - if (!currentTimeRestore) return true; - - const currentRange = timefilter.getTime(); - const lastRange = lastSaved?.timeRange ?? timefilter.getTimeDefaults(); - if ( - !areTimesEqual(currentRange.from, lastRange.from) || - !areTimesEqual(currentRange.to, lastRange.to) - ) { - return false; - } - - const currentInterval = timefilter.getRefreshInterval(); - const lastInterval = lastSaved?.refreshInterval ?? timefilter.getRefreshIntervalDefaults(); - if ( - currentInterval.pause !== lastInterval.pause || - currentInterval.value !== lastInterval.value - ) { - return false; - } - return true; -}; - -const getFiltersAreEqual = ( - filtersA: Filter[], - filtersB: Filter[], - ignorePinned?: boolean -): boolean => { - return compareFilters( - ignorePinned ? filtersA.filter((f) => !isFilterPinned(f)) : filtersA, - ignorePinned ? filtersB.filter((f) => !isFilterPinned(f)) : filtersB, - COMPARE_ALL_OPTIONS - ); -}; - -const getOptionsAreEqual = (optionsA: DashboardOptions, optionsB: DashboardOptions): boolean => { - const optionKeys = [ - ...(Object.keys(optionsA) as Array), - ...(Object.keys(optionsB) as Array), - ]; - for (const key of optionKeys) { - if (Boolean(optionsA[key]) !== Boolean(optionsB[key])) return false; - } - return true; -}; - -const getPanelsAreEqual = async ( - originalPanels: DashboardPanelMap, - newPanels: DashboardPanelMap, - getEmbeddable: (id: string) => Promise -): Promise => { - const originalEmbeddableIds = Object.keys(originalPanels); - const newEmbeddableIds = Object.keys(newPanels); - - const embeddableIdDiff = xor(originalEmbeddableIds, newEmbeddableIds); - if (embeddableIdDiff.length > 0) { - return false; - } - - // embeddable ids are equal so let's compare individual panels. - for (const embeddableId of newEmbeddableIds) { - const { - explicitInput: originalExplicitInput, - panelRefName: panelRefA, - ...commonPanelDiffOriginal - } = originalPanels[embeddableId]; - const { - explicitInput: newExplicitInput, - panelRefName: panelRefB, - ...commonPanelDiffNew - } = newPanels[embeddableId]; - - if (!isEmpty(commonDiff(commonPanelDiffOriginal, commonPanelDiffNew))) return false; - - // the position and type of this embeddable is equal. Now we compare the embeddable input - const embeddable = await getEmbeddable(embeddableId); - if (!(await embeddable.getExplicitInputIsEqual(originalExplicitInput))) return false; - } - return true; -}; - -const commonDiff = (originalObj: Partial, newObj: Partial) => { - const differences: Partial = {}; - const keys = [ - ...(Object.keys(originalObj) as Array), - ...(Object.keys(newObj) as Array), - ]; - for (const key of keys) { - if (key === undefined) continue; - if (!fastIsEqual(originalObj[key], newObj[key])) differences[key] = newObj[key]; - } - return differences; -}; diff --git a/src/plugins/dashboard/public/application/lib/filter_utils.ts b/src/plugins/dashboard/public/application/lib/filter_utils.ts deleted file mode 100644 index fb2762c7dc58..000000000000 --- a/src/plugins/dashboard/public/application/lib/filter_utils.ts +++ /dev/null @@ -1,59 +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 _ from 'lodash'; -import moment, { Moment } from 'moment'; -import type { Filter } from '@kbn/es-query'; - -/** - * Converts the time to a utc formatted string. If the time is not valid (e.g. it might be in a relative format like - * 'now-15m', then it just returns what it was passed). - * @param time {string|Moment} - * @returns the time represented in utc format, or if the time range was not able to be parsed into a moment - * object, it returns the same object it was given. - */ -export const convertTimeToUTCString = (time?: string | Moment): undefined | string => { - if (moment(time).isValid()) { - return moment(time).utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'); - } else { - // If it's not a valid moment date, then it should be a string representing a relative time - // like 'now' or 'now-15m'. - return time as string; - } -}; - -export const areTimesEqual = (timeA?: string | Moment, timeB?: string | Moment) => { - return convertTimeToUTCString(timeA) === convertTimeToUTCString(timeB); -}; - -/** - * Depending on how a dashboard is loaded, the filter object may contain a $$hashKey and $state that will throw - * off a filter comparison. This removes those variables. - * @param filters {Array.} - * @returns {Array.} - */ -export const cleanFiltersForComparison = (filters: Filter[]) => { - return _.map(filters, (filter) => { - const f: Partial = _.omit(filter, ['$$hashKey', '$state']); - if (f.meta) { - // f.meta.value is the value displayed in the filter bar. - // It may also be loaded differently and shouldn't be used in this comparison. - return _.omit(f.meta, ['value']); - } - return f; - }); -}; - -export const cleanFiltersForSerialize = (filters: Filter[]): Filter[] => { - return filters.map((filter) => { - if (filter.meta?.value) { - delete filter.meta.value; - } - return filter; - }); -}; diff --git a/src/plugins/dashboard/public/application/lib/help_menu_util.ts b/src/plugins/dashboard/public/application/lib/help_menu_util.ts deleted file mode 100644 index d93b2593386b..000000000000 --- a/src/plugins/dashboard/public/application/lib/help_menu_util.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { pluginServices } from '../../services/plugin_services'; - -export function addHelpMenuToAppChrome() { - const { - chrome: { setHelpExtension }, - documentationLinks: { dashboardDocLink }, - } = pluginServices.getServices(); - setHelpExtension({ - appName: i18n.translate('dashboard.helpMenu.appName', { - defaultMessage: 'Dashboards', - }), - links: [ - { - linkType: 'documentation', - href: `${dashboardDocLink}`, - }, - ], - }); -} diff --git a/src/plugins/dashboard/public/application/lib/index.ts b/src/plugins/dashboard/public/application/lib/index.ts deleted file mode 100644 index 0f364a31061d..000000000000 --- a/src/plugins/dashboard/public/application/lib/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { - areTimesEqual, - convertTimeToUTCString, - cleanFiltersForSerialize, - cleanFiltersForComparison, -} from './filter_utils'; -export { - createSessionRestorationDataProvider, - enableDashboardSearchSessions, - getSearchSessionIdFromURL, - getSessionURLObservable, -} from './dashboard_session_restoration'; -export { addHelpMenuToAppChrome } from './help_menu_util'; -export { diffDashboardState } from './diff_dashboard_state'; -export { syncDashboardUrlState } from './sync_dashboard_url_state'; -export { syncDashboardDataViews } from './sync_dashboard_data_views'; -export { syncDashboardFilterState } from './sync_dashboard_filter_state'; -export { stateToDashboardContainerInput } from './convert_dashboard_state'; -export { syncDashboardContainerInput } from './sync_dashboard_container_input'; -export { loadDashboardHistoryLocationState } from './load_dashboard_history_location_state'; -export { buildDashboardContainer, tryDestroyDashboardContainer } from './build_dashboard_container'; diff --git a/src/plugins/dashboard/public/application/lib/migrate_legacy_query.ts b/src/plugins/dashboard/public/application/lib/migrate_legacy_query.ts deleted file mode 100644 index e8c9ff022b46..000000000000 --- a/src/plugins/dashboard/public/application/lib/migrate_legacy_query.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { has } from 'lodash'; -import type { Query } from '@kbn/es-query'; -/** - * Creates a standardized query object from old queries that were either strings or pure ES query DSL - * - * @param query - a legacy query, what used to be stored in SearchSource's query property - * @return Object - */ - -export function migrateLegacyQuery(query: Query | { [key: string]: any } | string): Query { - // Lucene was the only option before, so language-less queries are all lucene - if (!has(query, 'language')) { - return { query, language: 'lucene' }; - } - - return query as Query; -} diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_container_input.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_container_input.ts deleted file mode 100644 index 9e97beaad276..000000000000 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_container_input.ts +++ /dev/null @@ -1,213 +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 _ from 'lodash'; -import { Subscription } from 'rxjs'; -import { debounceTime, tap } from 'rxjs/operators'; - -import { compareFilters, COMPARE_ALL_OPTIONS } from '@kbn/es-query'; -import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/public'; - -import type { DashboardContainer } from '../embeddable'; -import { DashboardConstants } from '../..'; -import { - setControlGroupState, - setExpandedPanelId, - setFullScreenMode, - setPanels, - setQuery, - setTimeRange, - setTimeslice, -} from '../state'; -import { diffDashboardContainerInput } from './diff_dashboard_state'; -import type { DashboardBuildContext, DashboardContainerInput } from '../../types'; -import { - getSearchSessionIdFromURL, - getSessionURLObservable, - stateToDashboardContainerInput, -} from '.'; -import { pluginServices } from '../../services/plugin_services'; - -type SyncDashboardContainerCommon = DashboardBuildContext & { - dashboardContainer: DashboardContainer; -}; - -type ApplyStateChangesToContainerProps = SyncDashboardContainerCommon & { - force: boolean; -}; - -type ApplyContainerChangesToStateProps = SyncDashboardContainerCommon; - -type SyncDashboardContainerProps = SyncDashboardContainerCommon & ApplyContainerChangesToStateProps; - -/** - * Sets up two way binding between dashboard container and redux state. - */ -export const syncDashboardContainerInput = ( - syncDashboardContainerProps: SyncDashboardContainerProps -) => { - const { history, dashboardContainer, $onDashboardStateChange, $triggerDashboardRefresh } = - syncDashboardContainerProps; - const subscriptions = new Subscription(); - subscriptions.add( - dashboardContainer - .getInput$() - .subscribe(() => applyContainerChangesToState(syncDashboardContainerProps)) - ); - subscriptions.add($onDashboardStateChange.subscribe(() => $triggerDashboardRefresh.next({}))); - subscriptions.add( - getSessionURLObservable(history).subscribe(() => { - $triggerDashboardRefresh.next({ force: true }); - }) - ); - - let forceRefresh: boolean = false; - subscriptions.add( - $triggerDashboardRefresh - .pipe( - tap((trigger) => { - forceRefresh = forceRefresh || (trigger?.force ?? false); - }), - debounceTime(DashboardConstants.CHANGE_APPLY_DEBOUNCE) - ) - .subscribe(() => { - applyStateChangesToContainer({ ...syncDashboardContainerProps, force: forceRefresh }); - - // If this dashboard has a control group, reload the control group when the refresh button is manually pressed. - if (forceRefresh && dashboardContainer.controlGroup) { - dashboardContainer.controlGroup.reload(); - } - forceRefresh = false; - }) - ); - - return () => subscriptions.unsubscribe(); -}; - -export const applyContainerChangesToState = ({ - dashboardContainer, - getLatestDashboardState, - dispatchDashboardStateChange, -}: ApplyContainerChangesToStateProps) => { - const { - data: { query }, - } = pluginServices.getServices(); - - const input = dashboardContainer.getInput(); - const latestState = getLatestDashboardState(); - if (Object.keys(latestState).length === 0) { - return; - } - const { filterManager } = query; - if (!compareFilters(input.filters, filterManager.getFilters(), COMPARE_ALL_OPTIONS)) { - // Add filters modifies the object passed to it, hence the clone deep. - filterManager.addFilters(_.cloneDeep(input.filters)); - } - - if (!_.isEqual(input.panels, latestState.panels)) { - dispatchDashboardStateChange(setPanels(input.panels)); - } - - if (!_.isEqual(input.query, latestState.query)) { - dispatchDashboardStateChange(setQuery(input.query)); - } - - if (input.timeRestore && !_.isEqual(input.timeRange, latestState.timeRange)) { - dispatchDashboardStateChange(setTimeRange(input.timeRange)); - } - - if (!_.isEqual(input.expandedPanelId, latestState.expandedPanelId)) { - dispatchDashboardStateChange(setExpandedPanelId(input.expandedPanelId)); - } - - if (!_.isEqual(input.controlGroupInput, latestState.controlGroupInput)) { - dispatchDashboardStateChange(setControlGroupState(input.controlGroupInput)); - } - dispatchDashboardStateChange(setFullScreenMode(input.isFullScreenMode)); - - if (!_.isEqual(input.timeslice, latestState.timeslice)) { - dispatchDashboardStateChange(setTimeslice(input.timeslice)); - } -}; - -export const applyStateChangesToContainer = ({ - force, - history, - dashboardContainer, - kbnUrlStateStorage, - isEmbeddedExternally, - getLatestDashboardState, -}: ApplyStateChangesToContainerProps) => { - const { - data: { search }, - } = pluginServices.getServices(); - - const latestState = getLatestDashboardState(); - if (Object.keys(latestState).length === 0) { - return; - } - const currentDashboardStateAsInput = stateToDashboardContainerInput({ - dashboardState: latestState, - isEmbeddedExternally, - }); - const differences = diffDashboardContainerInput( - dashboardContainer.getInput(), - currentDashboardStateAsInput - ); - if (force) { - differences.lastReloadRequestTime = Date.now(); - } - - if (Object.keys(differences).length !== 0) { - const shouldRefetch = Object.keys(differences).some( - (changeKey) => !noRefetchKeys.includes(changeKey as keyof DashboardContainerInput) - ); - - const newSearchSessionId: string | undefined = (() => { - // do not update session id if this is irrelevant state change to prevent excessive searches - if (!shouldRefetch) return; - - const sessionApi = search.session; - let searchSessionIdFromURL = getSearchSessionIdFromURL(history); - if (searchSessionIdFromURL) { - if (sessionApi.isRestore() && sessionApi.isCurrentSession(searchSessionIdFromURL)) { - // navigating away from a restored session - kbnUrlStateStorage.kbnUrlControls.updateAsync((nextUrl) => { - if (nextUrl.includes(DashboardConstants.SEARCH_SESSION_ID)) { - return replaceUrlHashQuery(nextUrl, (query) => { - delete query[DashboardConstants.SEARCH_SESSION_ID]; - return query; - }); - } - return nextUrl; - }); - searchSessionIdFromURL = undefined; - } else { - sessionApi.restore(searchSessionIdFromURL); - } - } - - return searchSessionIdFromURL ?? sessionApi.start(); - })(); - - dashboardContainer.updateInput({ - ...differences, - ...(newSearchSessionId && { searchSessionId: newSearchSessionId }), - }); - } -}; - -const noRefetchKeys: Readonly> = [ - 'title', - 'viewMode', - 'useMargins', - 'description', - 'expandedPanelId', - 'isFullScreenMode', - 'isEmbeddedExternally', -] as const; diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts deleted file mode 100644 index 0bce899e22fc..000000000000 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts +++ /dev/null @@ -1,154 +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 _ from 'lodash'; -import { merge } from 'rxjs'; -import { finalize, map, switchMap, tap } from 'rxjs/operators'; -import { - connectToQueryState, - GlobalQueryStateFromUrl, - syncGlobalQueryStateWithUrl, - waitUntilNextSessionCompletes$, -} from '@kbn/data-plugin/public'; -import type { Filter, Query } from '@kbn/es-query'; - -import { cleanFiltersForSerialize } from '.'; -import type { DashboardBuildContext, DashboardState } from '../../types'; -import { setFiltersAndQuery } from '../state/dashboard_state_slice'; -import { pluginServices } from '../../services/plugin_services'; - -type SyncDashboardFilterStateProps = DashboardBuildContext & { - initialDashboardState: DashboardState; -}; - -/** - * Applies initial state to the query service, and the saved dashboard search source, then - * Sets up syncing and subscriptions between the filter state from the Data plugin - * and the dashboard Redux store. - */ -export const syncDashboardFilterState = ({ - kbnUrlStateStorage, - initialDashboardState, - $checkForUnsavedChanges, - $onDashboardStateChange, - $triggerDashboardRefresh, - dispatchDashboardStateChange, -}: SyncDashboardFilterStateProps) => { - const { - data: { query: queryService, search }, - } = pluginServices.getServices(); - const { queryString, timefilter } = queryService; - const { timefilter: timefilterService } = timefilter; - - // apply initial dashboard filter state. - applyDashboardFilterState({ - currentDashboardState: initialDashboardState, - kbnUrlStateStorage, - }); - - // starts syncing `_g` portion of url with query services - const { stop: stopSyncingQueryServiceStateWithUrl } = syncGlobalQueryStateWithUrl( - queryService, - kbnUrlStateStorage - ); - - // starts syncing app filters between dashboard state and filterManager - const intermediateFilterState: { filters: Filter[]; query: Query } = { - query: initialDashboardState.query ?? queryString.getDefaultQuery(), - filters: initialDashboardState.filters ?? [], - }; - const stopSyncingAppFilters = connectToQueryState( - queryService, - { - get: () => intermediateFilterState, - set: ({ filters, query }) => { - intermediateFilterState.filters = cleanFiltersForSerialize(filters ?? []) || []; - intermediateFilterState.query = query || queryString.getDefaultQuery(); - dispatchDashboardStateChange(setFiltersAndQuery(intermediateFilterState)); - }, - state$: $onDashboardStateChange.pipe( - map((appState) => ({ - filters: appState.filters, - query: appState.query, - })) - ), - }, - { - query: true, - filters: true, - } - ); - - const timeRefreshSubscription = merge( - timefilterService.getRefreshIntervalUpdate$(), - timefilterService.getTimeUpdate$() - ).subscribe(() => { - $triggerDashboardRefresh.next({}); - - // manually check for unsaved changes here because the time range is not stored on the dashboardState, - // but it could trigger the unsaved changes badge. - $checkForUnsavedChanges.next(undefined); - }); - - const forceRefreshSubscription = timefilterService - .getAutoRefreshFetch$() - .pipe( - tap(() => { - $triggerDashboardRefresh.next({ force: true }); - }), - switchMap((done) => - // best way on a dashboard to estimate that panels are updated is to rely on search session service state - waitUntilNextSessionCompletes$(search.session).pipe(finalize(done)) - ) - ) - .subscribe(); - - const stopSyncingDashboardFilterState = () => { - forceRefreshSubscription.unsubscribe(); - timeRefreshSubscription.unsubscribe(); - stopSyncingQueryServiceStateWithUrl(); - stopSyncingAppFilters(); - }; - - return { stopSyncingDashboardFilterState }; -}; - -interface ApplyDashboardFilterStateProps { - kbnUrlStateStorage: DashboardBuildContext['kbnUrlStateStorage']; - currentDashboardState: DashboardState; -} - -export const applyDashboardFilterState = ({ - currentDashboardState, - kbnUrlStateStorage, -}: ApplyDashboardFilterStateProps) => { - const { - data: { - query: { filterManager, queryString, timefilter }, - }, - } = pluginServices.getServices(); - const { timefilter: timefilterService } = timefilter; - - // apply filters and query to the query service - filterManager.setAppFilters(_.cloneDeep(currentDashboardState.filters)); - queryString.setQuery(currentDashboardState.query); - - /** - * If a global time range is not set explicitly and the time range was saved with the dashboard, apply - * time range and refresh interval to the query service. - */ - if (currentDashboardState.timeRestore) { - const globalQueryState = kbnUrlStateStorage.get('_g'); - if (!globalQueryState?.time && currentDashboardState.timeRange) { - timefilterService.setTime(currentDashboardState.timeRange); - } - if (!globalQueryState?.refreshInterval && currentDashboardState.refreshInterval) { - timefilterService.setRefreshInterval(currentDashboardState.refreshInterval); - } - } -}; diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_url_state.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_url_state.ts deleted file mode 100644 index 31101ae3679f..000000000000 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_url_state.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import _ from 'lodash'; -import { debounceTime } from 'rxjs/operators'; -import semverSatisfies from 'semver/functions/satisfies'; - -import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/public'; - -import { setDashboardState } from '../state'; -import { migrateLegacyQuery } from './migrate_legacy_query'; -import { pluginServices } from '../../services/plugin_services'; -import { DASHBOARD_STATE_STORAGE_KEY } from '../../dashboard_constants'; -import { applyDashboardFilterState } from './sync_dashboard_filter_state'; -import { dashboardSavedObjectErrorStrings } from '../../dashboard_strings'; -import { convertSavedPanelsToPanelMap, DashboardPanelMap } from '../../../common'; -import type { DashboardBuildContext, DashboardState, RawDashboardState } from '../../types'; - -/** - * We no longer support loading panels from a version older than 7.3 in the URL. - * @returns whether or not there is a panel in the URL state saved with a version before 7.3 - */ -export const isPanelVersionTooOld = (panels: RawDashboardState['panels']) => { - for (const panel of panels) { - if (!panel.version || semverSatisfies(panel.version, '<7.3')) return true; - } - return false; -}; - -export const syncDashboardUrlState = ({ - dispatchDashboardStateChange, - getLatestDashboardState, - kbnUrlStateStorage, -}: DashboardBuildContext) => { - /** - * Loads any dashboard state from the URL, and removes the state from the URL. - */ - const loadAndRemoveDashboardState = (): Partial => { - const { - notifications: { toasts }, - } = pluginServices.getServices(); - const rawAppStateInUrl = kbnUrlStateStorage.get(DASHBOARD_STATE_STORAGE_KEY); - if (!rawAppStateInUrl) return {}; - - let panelsMap: DashboardPanelMap | undefined; - if (rawAppStateInUrl.panels && rawAppStateInUrl.panels.length > 0) { - if (isPanelVersionTooOld(rawAppStateInUrl.panels)) { - toasts.addWarning(dashboardSavedObjectErrorStrings.getPanelTooOldError()); - } else { - panelsMap = convertSavedPanelsToPanelMap(rawAppStateInUrl.panels); - } - } - - const migratedQuery = rawAppStateInUrl.query - ? migrateLegacyQuery(rawAppStateInUrl.query) - : undefined; - - const nextUrl = replaceUrlHashQuery(window.location.href, (query) => { - delete query[DASHBOARD_STATE_STORAGE_KEY]; - return query; - }); - kbnUrlStateStorage.kbnUrlControls.update(nextUrl, true); - - return { - ..._.omit(rawAppStateInUrl, ['panels', 'query']), - ...(migratedQuery ? { query: migratedQuery } : {}), - ...(panelsMap ? { panels: panelsMap } : {}), - }; - }; - - // load initial state before subscribing to avoid state removal triggering update. - const initialDashboardStateFromUrl = loadAndRemoveDashboardState(); - - const appStateSubscription = kbnUrlStateStorage - .change$(DASHBOARD_STATE_STORAGE_KEY) - .pipe(debounceTime(10)) // debounce URL updates so react has time to unsubscribe when changing URLs - .subscribe(() => { - const stateFromUrl = loadAndRemoveDashboardState(); - - const updatedDashboardState = { ...getLatestDashboardState(), ...stateFromUrl }; - applyDashboardFilterState({ - currentDashboardState: updatedDashboardState, - kbnUrlStateStorage, - }); - - if (Object.keys(stateFromUrl).length === 0) return; - dispatchDashboardStateChange(setDashboardState(updatedDashboardState)); - }); - - const stopWatchingAppStateInUrl = () => appStateSubscription.unsubscribe(); - return { initialDashboardStateFromUrl, stopWatchingAppStateInUrl }; -}; diff --git a/src/plugins/dashboard/public/application/state/dashboard_state_hooks.ts b/src/plugins/dashboard/public/application/state/dashboard_state_hooks.ts deleted file mode 100644 index abc346d4bbaa..000000000000 --- a/src/plugins/dashboard/public/application/state/dashboard_state_hooks.ts +++ /dev/null @@ -1,12 +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 { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; -import type { DashboardRootState, DashboardDispatch } from './dashboard_state_store'; - -export const useDashboardDispatch = () => useDispatch(); -export const useDashboardSelector: TypedUseSelectorHook = useSelector; diff --git a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts deleted file mode 100644 index 61a9c75dac41..000000000000 --- a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts +++ /dev/null @@ -1,143 +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 { createSlice, PayloadAction } from '@reduxjs/toolkit'; - -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { RefreshInterval } from '@kbn/data-plugin/common'; -import type { Filter, Query, TimeRange } from '@kbn/es-query'; -import { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; - -import { DashboardPanelMap } from '../../../common'; -import type { DashboardOptions, DashboardState } from '../../types'; - -export const dashboardStateSlice = createSlice({ - name: 'dashboardState', - initialState: {} as DashboardState, - reducers: { - setDashboardState: (state, action: PayloadAction) => { - return action.payload; - }, - updateState: (state, action: PayloadAction>) => { - state = { ...state, ...action.payload }; - }, - setDashboardOptions: (state, action: PayloadAction) => { - state.options = action.payload; - }, - setStateFromSaveModal: ( - state, - action: PayloadAction<{ - title: string; - description: string; - tags?: string[]; - timeRestore: boolean; - timeRange?: TimeRange; - refreshInterval?: RefreshInterval; - }> - ) => { - state.title = action.payload.title; - state.description = action.payload.description; - state.timeRestore = action.payload.timeRestore; - state.timeRange = action.payload.timeRange; - state.refreshInterval = action.payload.refreshInterval; - if (action.payload.tags) { - state.tags = action.payload.tags; - } - }, - setControlGroupState: ( - state, - action: PayloadAction - ) => { - state.controlGroupInput = action.payload; - }, - setUseMargins: (state, action: PayloadAction) => { - state.options.useMargins = action.payload; - }, - setSyncColors: (state, action: PayloadAction) => { - state.options.syncColors = action.payload; - }, - setSyncCursor: (state, action: PayloadAction) => { - state.options.syncCursor = action.payload; - }, - setSyncTooltips: (state, action: PayloadAction) => { - state.options.syncTooltips = action.payload; - }, - setHidePanelTitles: (state, action: PayloadAction) => { - state.options.hidePanelTitles = action.payload; - }, - setPanels: (state, action: PayloadAction) => { - state.panels = action.payload; - }, - setExpandedPanelId: (state, action: PayloadAction) => { - state.expandedPanelId = action.payload; - }, - setFullScreenMode: (state, action: PayloadAction) => { - state.fullScreenMode = action.payload; - }, - setSavedQueryId: (state, action: PayloadAction) => { - state.savedQuery = action.payload; - }, - setTimeRestore: (state, action: PayloadAction) => { - state.timeRestore = action.payload; - }, - setTimeRange: (state, action: PayloadAction) => { - state.timeRange = action.payload; - }, - setDescription: (state, action: PayloadAction) => { - state.description = action.payload; - }, - setViewMode: (state, action: PayloadAction) => { - state.viewMode = action.payload; - }, - setFiltersAndQuery: (state, action: PayloadAction<{ filters: Filter[]; query: Query }>) => { - state.filters = action.payload.filters; - state.query = action.payload.query; - }, - setFilters: (state, action: PayloadAction) => { - state.filters = action.payload; - }, - setTags: (state, action: PayloadAction) => { - state.tags = action.payload; - }, - setTitle: (state, action: PayloadAction) => { - state.description = action.payload; - }, - setQuery: (state, action: PayloadAction) => { - state.query = action.payload; - }, - setTimeslice: (state, action: PayloadAction<[number, number] | undefined>) => { - state.timeslice = action.payload; - }, - }, -}); - -export const { - setStateFromSaveModal, - setControlGroupState, - setDashboardOptions, - setExpandedPanelId, - setHidePanelTitles, - setFiltersAndQuery, - setDashboardState, - setFullScreenMode, - setSavedQueryId, - setDescription, - setTimeRestore, - setTimeRange, - setSyncColors, - setSyncTooltips, - setSyncCursor, - setUseMargins, - setViewMode, - setFilters, - setPanels, - setTitle, - setQuery, - setTimeslice, - setTags, -} = dashboardStateSlice.actions; diff --git a/src/plugins/dashboard/public/application/state/dashboard_state_store.ts b/src/plugins/dashboard/public/application/state/dashboard_state_store.ts deleted file mode 100644 index 76bc2c2fb1c7..000000000000 --- a/src/plugins/dashboard/public/application/state/dashboard_state_store.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { configureStore } from '@reduxjs/toolkit'; -import { dashboardStateSlice } from './dashboard_state_slice'; - -export const dashboardStateStore = configureStore({ - reducer: { dashboardStateReducer: dashboardStateSlice.reducer }, -}); - -export type DashboardRootState = ReturnType; -export type DashboardDispatch = typeof dashboardStateStore.dispatch; diff --git a/src/plugins/dashboard/public/application/test_helpers/get_sample_dashboard_input.ts b/src/plugins/dashboard/public/application/test_helpers/get_sample_dashboard_input.ts deleted file mode 100644 index e782bf8fe81c..000000000000 --- a/src/plugins/dashboard/public/application/test_helpers/get_sample_dashboard_input.ts +++ /dev/null @@ -1,54 +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 { ViewMode, EmbeddableInput } from '@kbn/embeddable-plugin/public'; -import { DashboardContainerInput } from '../..'; -import { DashboardPanelState } from '../embeddable'; - -export function getSampleDashboardInput( - overrides?: Partial -): DashboardContainerInput { - return { - id: '123', - filters: [], - useMargins: false, - isEmbeddedExternally: false, - isFullScreenMode: false, - title: 'My Dashboard', - query: { - language: 'kuery', - query: 'hi', - }, - timeRange: { - to: 'now', - from: 'now-15m', - }, - timeRestore: false, - viewMode: ViewMode.VIEW, - panels: {}, - ...overrides, - }; -} - -export function getSampleDashboardPanel( - overrides: Partial> & { - explicitInput: { id: string }; - type: string; - } -): DashboardPanelState { - return { - gridData: { - h: 15, - w: 15, - x: 0, - y: 0, - i: overrides.explicitInput.id, - }, - ...overrides, - }; -} diff --git a/src/plugins/dashboard/public/application/test_helpers/index.ts b/src/plugins/dashboard/public/application/test_helpers/index.ts deleted file mode 100644 index c4d149e8c10b..000000000000 --- a/src/plugins/dashboard/public/application/test_helpers/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { getSampleDashboardInput, getSampleDashboardPanel } from './get_sample_dashboard_input'; -export { setupIntersectionObserverMock } from './intersection_observer_mock'; diff --git a/src/plugins/dashboard/public/application/test_helpers/intersection_observer_mock.ts b/src/plugins/dashboard/public/application/test_helpers/intersection_observer_mock.ts deleted file mode 100644 index 401ec5acdee4..000000000000 --- a/src/plugins/dashboard/public/application/test_helpers/intersection_observer_mock.ts +++ /dev/null @@ -1,47 +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. - */ - -/** - * Utility function that mocks the `IntersectionObserver` API. Necessary for components that rely - * on it, otherwise the tests will crash. Recommended to execute inside `beforeEach`. - * - * @param intersectionObserverMock - Parameter that is sent to the `Object.defineProperty` - * overwrite method. `jest.fn()` mock functions can be passed here if the goal is to not only - * mock the intersection observer, but its methods. - */ -export function setupIntersectionObserverMock({ - root = null, - rootMargin = '', - thresholds = [], - disconnect = () => null, - observe = () => null, - takeRecords = () => [], - unobserve = () => null, -} = {}): void { - class MockIntersectionObserver implements IntersectionObserver { - readonly root: Element | null = root; - readonly rootMargin: string = rootMargin; - readonly thresholds: readonly number[] = thresholds; - disconnect: () => void = disconnect; - observe: (target: Element) => void = observe; - takeRecords: () => IntersectionObserverEntry[] = takeRecords; - unobserve: (target: Element) => void = unobserve; - } - - Object.defineProperty(window, 'IntersectionObserver', { - writable: true, - configurable: true, - value: MockIntersectionObserver, - }); - - Object.defineProperty(global, 'IntersectionObserver', { - writable: true, - configurable: true, - value: MockIntersectionObserver, - }); -} diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx deleted file mode 100644 index 3e9697d56d65..000000000000 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ /dev/null @@ -1,616 +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 UseUnmount from 'react-use/lib/useUnmount'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; - -import { - withSuspense, - LazyLabsFlyout, - SolutionToolbar, - QuickButtonGroup, - QuickButtonProps, - PrimaryActionButton, - AddFromLibraryButton, -} from '@kbn/presentation-util-plugin/public'; -import { - showSaveModal, - type SaveResult, - getSavedObjectFinder, -} from '@kbn/saved-objects-plugin/public'; -import { METRIC_TYPE } from '@kbn/analytics'; -import { Required } from '@kbn/utility-types'; -import { EuiHorizontalRule } from '@elastic/eui'; -import type { OverlayRef } from '@kbn/core/public'; -import type { SavedQuery } from '@kbn/data-plugin/common'; -import type { TopNavMenuProps } from '@kbn/navigation-plugin/public'; -import type { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public'; -import { isErrorEmbeddable, openAddPanelFlyout, ViewMode } from '@kbn/embeddable-plugin/public'; - -import { - setFullScreenMode, - setHidePanelTitles, - setSavedQueryId, - setStateFromSaveModal, - setSyncColors, - setSyncTooltips, - setSyncCursor, - setUseMargins, - setViewMode, - useDashboardDispatch, - useDashboardSelector, -} from '../state'; -import { TopNavIds } from './top_nav_ids'; -import { EditorMenu } from './editor_menu'; -import { UI_SETTINGS } from '../../../common'; -import { DashboardSaveModal } from './save_modal'; -import { showCloneModal } from './show_clone_modal'; -import { ShowShareModal } from './show_share_modal'; -import { getTopNavConfig } from './get_top_nav_config'; -import { showOptionsPopover } from './show_options_popover'; -import { pluginServices } from '../../services/plugin_services'; -import { DashboardEmbedSettings, DashboardRedirect, DashboardState } from '../../types'; -import { confirmDiscardUnsavedChanges } from '../listing/confirm_overlays'; -import { useDashboardMountContext } from '../hooks/dashboard_mount_context'; -import { DashboardConstants, getFullEditPath } from '../../dashboard_constants'; -import { DashboardAppState, DashboardSaveOptions, NavAction } from '../../types'; -import { getCreateVisualizationButtonTitle, unsavedChangesBadge } from '../../dashboard_strings'; - -export interface DashboardTopNavState { - chromeIsVisible: boolean; - addPanelOverlay?: OverlayRef; - savedQuery?: SavedQuery; - isSaveInProgress?: boolean; -} - -type CompleteDashboardAppState = Required< - DashboardAppState, - 'getLatestDashboardState' | 'dashboardContainer' ->; - -export const isCompleteDashboardAppState = ( - state: DashboardAppState -): state is CompleteDashboardAppState => { - return Boolean(state.getLatestDashboardState) && Boolean(state.dashboardContainer); -}; - -export interface DashboardTopNavProps { - dashboardAppState: CompleteDashboardAppState; - embedSettings?: DashboardEmbedSettings; - redirectTo: DashboardRedirect; - printMode: boolean; -} - -const LabsFlyout = withSuspense(LazyLabsFlyout, null); - -export function DashboardTopNav({ - dashboardAppState, - embedSettings, - redirectTo, - printMode, -}: DashboardTopNavProps) { - const { setHeaderActionMenu } = useDashboardMountContext(); - const { - dashboardSavedObject: { - checkForDuplicateDashboardTitle, - saveDashboardStateToSavedObject, - savedObjectsClient, - }, - chrome: { - getIsVisible$: getChromeIsVisible$, - recentlyAccessed: chromeRecentlyAccessed, - docTitle, - }, - coreContext: { i18nContext }, - share, - overlays, - notifications, - usageCollection, - data: { query, search }, - navigation: { TopNavMenu }, - settings: { uiSettings, theme }, - initializerContext: { allowByValueEmbeddables }, - dashboardCapabilities: { showWriteControls, saveQuery: showSaveQuery }, - savedObjectsTagging: { hasApi: hasSavedObjectsTagging }, - embeddable: { getEmbeddableFactory, getEmbeddableFactories, getStateTransfer }, - visualizations: { get: getVisualization, getAliases: getVisTypeAliases }, - } = pluginServices.getServices(); - - const dispatchDashboardStateChange = useDashboardDispatch(); - const dashboardState = useDashboardSelector((state) => state.dashboardStateReducer); - - const [mounted, setMounted] = useState(true); - const [state, setState] = useState({ chromeIsVisible: false }); - const [isLabsShown, setIsLabsShown] = useState(false); - - const lensAlias = getVisTypeAliases().find(({ name }) => name === 'lens'); - const quickButtonVisTypes = ['markdown', 'maps']; - const stateTransferService = getStateTransfer(); - const IS_DARK_THEME = uiSettings.get('theme:darkMode'); - const isLabsEnabled = uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI); - - const trackUiMetric = usageCollection.reportUiCounter?.bind( - usageCollection, - DashboardConstants.DASHBOARD_ID - ); - - useEffect(() => { - const visibleSubscription = getChromeIsVisible$().subscribe((chromeIsVisible) => { - setState((s) => ({ ...s, chromeIsVisible })); - }); - const { savedObjectId, title, viewMode } = dashboardState; - if (savedObjectId && title) { - chromeRecentlyAccessed.add( - getFullEditPath(savedObjectId, viewMode === ViewMode.EDIT), - title, - savedObjectId - ); - } - return () => { - visibleSubscription.unsubscribe(); - }; - }, [allowByValueEmbeddables, chromeRecentlyAccessed, dashboardState, getChromeIsVisible$]); - - const addFromLibrary = useCallback(() => { - if (!isErrorEmbeddable(dashboardAppState.dashboardContainer)) { - setState((s) => ({ - ...s, - addPanelOverlay: openAddPanelFlyout({ - embeddable: dashboardAppState.dashboardContainer, - getAllFactories: getEmbeddableFactories, - getFactory: getEmbeddableFactory, - notifications, - overlays, - SavedObjectFinder: getSavedObjectFinder({ client: savedObjectsClient }, uiSettings), - reportUiCounter: usageCollection.reportUiCounter, - theme, - }), - })); - } - }, [ - dashboardAppState.dashboardContainer, - usageCollection.reportUiCounter, - getEmbeddableFactories, - getEmbeddableFactory, - savedObjectsClient, - notifications, - overlays, - uiSettings, - theme, - ]); - - const createNewVisType = useCallback( - (visType?: BaseVisType | VisTypeAlias) => () => { - let path = ''; - let appId = ''; - - if (visType) { - if (trackUiMetric) { - trackUiMetric(METRIC_TYPE.CLICK, `${visType.name}:create`); - } - - if ('aliasPath' in visType) { - appId = visType.aliasApp; - path = visType.aliasPath; - } else { - appId = 'visualize'; - path = `#/create?type=${encodeURIComponent(visType.name)}`; - } - } else { - appId = 'visualize'; - path = '#/create?'; - } - - stateTransferService.navigateToEditor(appId, { - path, - state: { - originatingApp: DashboardConstants.DASHBOARDS_ID, - searchSessionId: search.session.getSessionId(), - }, - }); - }, - [stateTransferService, search.session, trackUiMetric] - ); - - const closeAllFlyouts = useCallback(() => { - dashboardAppState.dashboardContainer.controlGroup?.closeAllFlyouts(); - if (state.addPanelOverlay) { - state.addPanelOverlay.close(); - setState((s) => ({ ...s, addPanelOverlay: undefined })); - } - }, [state.addPanelOverlay, dashboardAppState.dashboardContainer.controlGroup]); - - const onChangeViewMode = useCallback( - (newMode: ViewMode) => { - closeAllFlyouts(); - const willLoseChanges = newMode === ViewMode.VIEW && dashboardAppState.hasUnsavedChanges; - - if (!willLoseChanges) { - dispatchDashboardStateChange(setViewMode(newMode)); - return; - } - - confirmDiscardUnsavedChanges(() => dashboardAppState.resetToLastSavedState?.()); - }, - [closeAllFlyouts, dashboardAppState, dispatchDashboardStateChange] - ); - - const runSaveAs = useCallback(async () => { - const currentState = dashboardAppState.getLatestDashboardState(); - const onSave = async ({ - newTags, - newTitle, - newDescription, - newCopyOnSave, - newTimeRestore, - onTitleDuplicate, - isTitleDuplicateConfirmed, - }: DashboardSaveOptions): Promise => { - const { - timefilter: { timefilter }, - } = query; - - const saveOptions = { - confirmOverwrite: false, - isTitleDuplicateConfirmed, - onTitleDuplicate, - saveAsCopy: newCopyOnSave, - }; - const stateFromSaveModal: Pick< - DashboardState, - 'title' | 'description' | 'timeRestore' | 'timeRange' | 'refreshInterval' | 'tags' - > = { - title: newTitle, - tags: [] as string[], - description: newDescription, - timeRestore: newTimeRestore, - timeRange: newTimeRestore ? timefilter.getTime() : undefined, - refreshInterval: newTimeRestore ? timefilter.getRefreshInterval() : undefined, - }; - if (hasSavedObjectsTagging && newTags) { - // remove `hasSavedObjectsTagging` once the savedObjectsTagging service is optional - stateFromSaveModal.tags = newTags; - } - - if ( - !(await checkForDuplicateDashboardTitle({ - title: newTitle, - onTitleDuplicate, - lastSavedTitle: currentState.title, - copyOnSave: newCopyOnSave, - isTitleDuplicateConfirmed, - })) - ) { - // do not save if title is duplicate and is unconfirmed - return {}; - } - - const saveResult = await saveDashboardStateToSavedObject({ - redirectTo, - saveOptions, - currentState: { ...currentState, ...stateFromSaveModal }, - }); - if (saveResult.id && !saveResult.redirected) { - dispatchDashboardStateChange(setStateFromSaveModal(stateFromSaveModal)); - setTimeout(() => { - /** - * set timeout so dashboard state subject can update with the new title before updating the last saved state. - * TODO: Remove this timeout once the last saved state is also handled in Redux. - **/ - dashboardAppState.updateLastSavedState?.(); - docTitle.change(stateFromSaveModal.title); - }, 1); - } - return saveResult.id ? { id: saveResult.id } : { error: new Error(saveResult.error) }; - }; - - const lastDashboardId = currentState.savedObjectId; - - const dashboardSaveModal = ( - {}} - tags={currentState.tags} - title={currentState.title} - timeRestore={currentState.timeRestore} - description={currentState.description} - showCopyOnSave={lastDashboardId ? true : false} - /> - ); - closeAllFlyouts(); - showSaveModal(dashboardSaveModal, i18nContext); - }, [ - saveDashboardStateToSavedObject, - checkForDuplicateDashboardTitle, - dispatchDashboardStateChange, - hasSavedObjectsTagging, - dashboardAppState, - closeAllFlyouts, - i18nContext, - redirectTo, - docTitle, - query, - ]); - - const runQuickSave = useCallback(async () => { - setState((s) => ({ ...s, isSaveInProgress: true })); - const currentState = dashboardAppState.getLatestDashboardState(); - const saveResult = await saveDashboardStateToSavedObject({ - redirectTo, - currentState, - saveOptions: {}, - }); - if (saveResult.id && !saveResult.redirected) { - dashboardAppState.updateLastSavedState?.(); - } - // turn off save in progress after the next change check. This prevents the save button from flashing - setTimeout(() => { - if (!mounted) return; - setState((s) => ({ ...s, isSaveInProgress: false })); - }, DashboardConstants.CHANGE_CHECK_DEBOUNCE); - }, [dashboardAppState, saveDashboardStateToSavedObject, redirectTo, mounted]); - - const runClone = useCallback(() => { - const currentState = dashboardAppState.getLatestDashboardState(); - const onClone = async ( - newTitle: string, - isTitleDuplicateConfirmed: boolean, - onTitleDuplicate: () => void - ) => { - if ( - !(await checkForDuplicateDashboardTitle({ - title: newTitle, - onTitleDuplicate, - lastSavedTitle: currentState.title, - copyOnSave: true, - isTitleDuplicateConfirmed, - })) - ) { - // do not clone if title is duplicate and is unconfirmed - return {}; - } - - const saveResult = await saveDashboardStateToSavedObject({ - redirectTo, - saveOptions: { saveAsCopy: true }, - currentState: { ...currentState, title: newTitle }, - }); - return saveResult.id ? { id: saveResult.id } : { error: saveResult.error }; - }; - showCloneModal({ onClone, title: currentState.title }); - }, [ - checkForDuplicateDashboardTitle, - saveDashboardStateToSavedObject, - dashboardAppState, - redirectTo, - ]); - - const showOptions = useCallback( - (anchorElement: HTMLElement) => { - const currentState = dashboardAppState.getLatestDashboardState(); - showOptionsPopover({ - anchorElement, - useMargins: currentState.options.useMargins, - onUseMarginsChange: (isChecked: boolean) => { - dispatchDashboardStateChange(setUseMargins(isChecked)); - }, - syncColors: Boolean(currentState.options.syncColors), - onSyncColorsChange: (isChecked: boolean) => { - dispatchDashboardStateChange(setSyncColors(isChecked)); - }, - syncCursor: currentState.options.syncCursor ?? true, - onSyncCursorChange: (isChecked: boolean) => { - dispatchDashboardStateChange(setSyncCursor(isChecked)); - }, - syncTooltips: Boolean(currentState.options.syncTooltips), - onSyncTooltipsChange: (isChecked: boolean) => { - dispatchDashboardStateChange(setSyncTooltips(isChecked)); - }, - hidePanelTitles: currentState.options.hidePanelTitles, - onHidePanelTitlesChange: (isChecked: boolean) => { - dispatchDashboardStateChange(setHidePanelTitles(isChecked)); - }, - }); - }, - [dashboardAppState, dispatchDashboardStateChange] - ); - - const showShare = useCallback( - (anchorElement: HTMLElement) => { - const currentState = dashboardAppState.getLatestDashboardState(); - ShowShareModal({ - anchorElement, - currentDashboardState: currentState, - isDirty: Boolean(dashboardAppState.hasUnsavedChanges), - }); - }, - [dashboardAppState] - ); - - const dashboardTopNavActions = useMemo(() => { - const actions = { - [TopNavIds.FULL_SCREEN]: () => dispatchDashboardStateChange(setFullScreenMode(true)), - [TopNavIds.EXIT_EDIT_MODE]: () => onChangeViewMode(ViewMode.VIEW), - [TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT), - [TopNavIds.QUICK_SAVE]: runQuickSave, - [TopNavIds.OPTIONS]: showOptions, - [TopNavIds.SAVE]: runSaveAs, - [TopNavIds.CLONE]: runClone, - } as { [key: string]: NavAction }; - - if (share !== {}) { - // TODO: Clean up this logic once share is optional - actions[TopNavIds.SHARE] = showShare; - } - - if (isLabsEnabled) { - actions[TopNavIds.LABS] = () => { - setIsLabsShown(!isLabsShown); - }; - } - return actions; - }, [ - dispatchDashboardStateChange, - onChangeViewMode, - runQuickSave, - showOptions, - runSaveAs, - showShare, - runClone, - share, - isLabsEnabled, - isLabsShown, - ]); - - UseUnmount(() => { - closeAllFlyouts(); - setMounted(false); - }); - - const getNavBarProps = (): TopNavMenuProps => { - const { hasUnsavedChanges } = dashboardAppState; - const shouldShowNavBarComponent = (forceShow: boolean): boolean => - (forceShow || state.chromeIsVisible) && !dashboardState.fullScreenMode; - - const shouldShowFilterBar = (forceHide: boolean): boolean => - !forceHide && (query.filterManager.getFilters().length > 0 || !dashboardState.fullScreenMode); - - const isFullScreenMode = dashboardState.fullScreenMode; - const showTopNavMenu = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowTopNavMenu)); - const showQueryInput = shouldShowNavBarComponent( - Boolean(embedSettings?.forceShowQueryInput || printMode) - ); - const showDatePicker = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker)); - const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); - const showQueryBar = showQueryInput || showDatePicker || showFilterBar; - const showSearchBar = showQueryBar || showFilterBar; - const screenTitle = dashboardState.title; - - const topNav = getTopNavConfig( - dashboardAppState.getLatestDashboardState().viewMode, - dashboardTopNavActions, - { - isLabsEnabled, - showWriteControls, - isSaveInProgress: state.isSaveInProgress, - isNewDashboard: !dashboardState.savedObjectId, - isDirty: Boolean(dashboardAppState.hasUnsavedChanges), - } - ); - - const badges = - hasUnsavedChanges && dashboardState.viewMode === ViewMode.EDIT - ? [ - { - 'data-test-subj': 'dashboardUnsavedChangesBadge', - badgeText: unsavedChangesBadge.getUnsavedChangedBadgeText(), - color: 'success', - }, - ] - : undefined; - - return { - badges, - screenTitle, - showSearchBar, - showFilterBar, - showSaveQuery, - showQueryInput, - showDatePicker, - appName: 'dashboard', - useDefaultBehaviors: true, - visible: printMode !== true, - savedQuery: state.savedQuery, - savedQueryId: dashboardState.savedQuery, - indexPatterns: dashboardAppState.dataViews, - config: showTopNavMenu ? topNav : undefined, - setMenuMountPoint: embedSettings ? undefined : setHeaderActionMenu, - className: isFullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined, - onQuerySubmit: (_payload, isUpdate) => { - if (isUpdate === false) { - dashboardAppState.$triggerDashboardRefresh.next({ force: true }); - } - }, - onSavedQueryIdChange: (newId: string | undefined) => { - dispatchDashboardStateChange(setSavedQueryId(newId)); - }, - }; - }; - - const getVisTypeQuickButton = (visTypeName: string) => { - const visType = - getVisualization(visTypeName) || getVisTypeAliases().find(({ name }) => name === visTypeName); - - if (visType) { - if ('aliasPath' in visType) { - const { name, icon, title } = visType as VisTypeAlias; - - return { - iconType: icon, - createType: title, - onClick: createNewVisType(visType as VisTypeAlias), - 'data-test-subj': `dashboardQuickButton${name}`, - }; - } else { - const { name, icon, title, titleInWizard } = visType as BaseVisType; - - return { - iconType: icon, - createType: titleInWizard || title, - onClick: createNewVisType(visType as BaseVisType), - 'data-test-subj': `dashboardQuickButton${name}`, - }; - } - } - - return; - }; - - const quickButtons = quickButtonVisTypes - .map(getVisTypeQuickButton) - .filter((button) => button) as QuickButtonProps[]; - - return ( - <> - - {!printMode && isLabsEnabled && isLabsShown ? ( - setIsLabsShown(false)} /> - ) : null} - {dashboardState.viewMode !== ViewMode.VIEW && !printMode ? ( - <> - - - {{ - primaryActionButton: ( - - ), - quickButtonGroup: , - extraButtons: [ - , - , - dashboardAppState.dashboardContainer.controlGroup?.getToolbarButtons(), - ], - }} - - - ) : null} - - ); -} diff --git a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts deleted file mode 100644 index 5047ec02f9f6..000000000000 --- a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts +++ /dev/null @@ -1,242 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { TopNavMenuData } from '@kbn/navigation-plugin/public'; -import { TopNavIds } from './top_nav_ids'; -import { NavAction } from '../../types'; - -/** - * @param actions - A mapping of TopNavIds to an action function that should run when the - * corresponding top nav is clicked. - * @param showWriteControls if false, does not include any controls that allow editing or creating objects. - * @return an array of objects for a top nav configuration, based on the mode. - */ -export function getTopNavConfig( - dashboardMode: ViewMode, - actions: { [key: string]: NavAction }, - options: { - showWriteControls: boolean; - isNewDashboard: boolean; - isDirty: boolean; - isSaveInProgress?: boolean; - isLabsEnabled?: boolean; - } -) { - const labs = options.isLabsEnabled ? [getLabsConfig(actions[TopNavIds.LABS])] : []; - switch (dashboardMode) { - case ViewMode.VIEW: - return !options.showWriteControls - ? [ - ...labs, - getFullScreenConfig(actions[TopNavIds.FULL_SCREEN]), - getShareConfig(actions[TopNavIds.SHARE]), - ] - : [ - ...labs, - getFullScreenConfig(actions[TopNavIds.FULL_SCREEN]), - getShareConfig(actions[TopNavIds.SHARE]), - getCloneConfig(actions[TopNavIds.CLONE]), - getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE]), - ]; - case ViewMode.EDIT: - const disableButton = options.isSaveInProgress; - const navItems: TopNavMenuData[] = [ - ...labs, - getOptionsConfig(actions[TopNavIds.OPTIONS], disableButton), - getShareConfig(actions[TopNavIds.SHARE], disableButton), - ]; - if (!options.isNewDashboard) { - navItems.push( - getSaveConfig(actions[TopNavIds.SAVE], options.isNewDashboard, disableButton) - ); - navItems.push(getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE], disableButton)); - navItems.push(getQuickSave(actions[TopNavIds.QUICK_SAVE], disableButton, options.isDirty)); - } else { - navItems.push(getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE], true)); - navItems.push( - getSaveConfig(actions[TopNavIds.SAVE], options.isNewDashboard, disableButton) - ); - } - return navItems; - default: - return []; - } -} - -function getSaveButtonLabel() { - return i18n.translate('dashboard.topNave.saveButtonAriaLabel', { - defaultMessage: 'save', - }); -} - -function getSaveAsButtonLabel() { - return i18n.translate('dashboard.topNave.saveAsButtonAriaLabel', { - defaultMessage: 'save as', - }); -} - -function getFullScreenConfig(action: NavAction) { - return { - id: 'full-screen', - label: i18n.translate('dashboard.topNave.fullScreenButtonAriaLabel', { - defaultMessage: 'full screen', - }), - description: i18n.translate('dashboard.topNave.fullScreenConfigDescription', { - defaultMessage: 'Full Screen Mode', - }), - testId: 'dashboardFullScreenMode', - run: action, - }; -} - -function getLabsConfig(action: NavAction) { - return { - id: 'labs', - label: i18n.translate('dashboard.topNav.labsButtonAriaLabel', { - defaultMessage: 'labs', - }), - description: i18n.translate('dashboard.topNav.labsConfigDescription', { - defaultMessage: 'Labs', - }), - testId: 'dashboardLabs', - run: action, - }; -} - -/** - * @returns {kbnTopNavConfig} - */ -function getEditConfig(action: NavAction) { - return { - emphasize: true, - id: 'edit', - iconType: 'pencil', - label: i18n.translate('dashboard.topNave.editButtonAriaLabel', { - defaultMessage: 'edit', - }), - description: i18n.translate('dashboard.topNave.editConfigDescription', { - defaultMessage: 'Switch to edit mode', - }), - testId: 'dashboardEditMode', - // We want to hide the "edit" button on small screens, since those have a responsive - // layout, which is not tied to the grid anymore, so we cannot edit the grid on that screens. - className: 'eui-hideFor--s eui-hideFor--xs', - run: action, - }; -} - -/** - * @returns {kbnTopNavConfig} - */ -function getQuickSave(action: NavAction, isLoading?: boolean, isDirty?: boolean) { - return { - isLoading, - disableButton: !isDirty, - id: 'quick-save', - iconType: 'save', - emphasize: true, - label: getSaveButtonLabel(), - description: i18n.translate('dashboard.topNave.saveConfigDescription', { - defaultMessage: 'Quick save your dashboard without any prompts', - }), - testId: 'dashboardQuickSaveMenuItem', - run: action, - }; -} - -/** - * @returns {kbnTopNavConfig} - */ -function getSaveConfig(action: NavAction, isNewDashboard = false, disableButton?: boolean) { - return { - disableButton, - id: 'save', - label: isNewDashboard ? getSaveButtonLabel() : getSaveAsButtonLabel(), - iconType: isNewDashboard ? 'save' : undefined, - description: i18n.translate('dashboard.topNave.saveAsConfigDescription', { - defaultMessage: 'Save as a new dashboard', - }), - testId: 'dashboardSaveMenuItem', - run: action, - emphasize: isNewDashboard, - }; -} - -/** - * @returns {kbnTopNavConfig} - */ -function getViewConfig(action: NavAction, disableButton?: boolean) { - return { - disableButton, - id: 'cancel', - label: i18n.translate('dashboard.topNave.cancelButtonAriaLabel', { - defaultMessage: 'Switch to view mode', - }), - description: i18n.translate('dashboard.topNave.viewConfigDescription', { - defaultMessage: 'Switch to view-only mode', - }), - testId: 'dashboardViewOnlyMode', - run: action, - }; -} - -/** - * @returns {kbnTopNavConfig} - */ -function getCloneConfig(action: NavAction) { - return { - id: 'clone', - label: i18n.translate('dashboard.topNave.cloneButtonAriaLabel', { - defaultMessage: 'clone', - }), - description: i18n.translate('dashboard.topNave.cloneConfigDescription', { - defaultMessage: 'Create a copy of your dashboard', - }), - testId: 'dashboardClone', - run: action, - }; -} - -/** - * @returns {kbnTopNavConfig} - */ -function getShareConfig(action: NavAction | undefined, disableButton?: boolean) { - return { - id: 'share', - label: i18n.translate('dashboard.topNave.shareButtonAriaLabel', { - defaultMessage: 'share', - }), - description: i18n.translate('dashboard.topNave.shareConfigDescription', { - defaultMessage: 'Share Dashboard', - }), - testId: 'shareTopNavButton', - run: action ?? (() => {}), - // disable the Share button if no action specified - disableButton: !action || disableButton, - }; -} - -/** - * @returns {kbnTopNavConfig} - */ -function getOptionsConfig(action: NavAction, disableButton?: boolean) { - return { - disableButton, - id: 'options', - label: i18n.translate('dashboard.topNave.optionsButtonAriaLabel', { - defaultMessage: 'options', - }), - description: i18n.translate('dashboard.topNave.optionsConfigDescription', { - defaultMessage: 'Options', - }), - testId: 'dashboardOptionsButton', - run: action, - }; -} diff --git a/src/plugins/dashboard/public/application/top_nav/options.tsx b/src/plugins/dashboard/public/application/top_nav/options.tsx deleted file mode 100644 index 65a41c7099af..000000000000 --- a/src/plugins/dashboard/public/application/top_nav/options.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { Component } from 'react'; -import { i18n } from '@kbn/i18n'; - -import { EuiForm, EuiFormRow, EuiSwitch } from '@elastic/eui'; - -interface Props { - useMargins: boolean; - onUseMarginsChange: (useMargins: boolean) => void; - hidePanelTitles: boolean; - onHidePanelTitlesChange: (hideTitles: boolean) => void; - syncColors: boolean; - onSyncColorsChange: (syncColors: boolean) => void; - syncCursor: boolean; - onSyncCursorChange: (syncCursor: boolean) => void; - syncTooltips: boolean; - onSyncTooltipsChange: (syncTooltips: boolean) => void; -} - -interface State { - useMargins: boolean; - hidePanelTitles: boolean; - syncColors: boolean; - syncCursor: boolean; - syncTooltips: boolean; -} - -export class OptionsMenu extends Component { - state = { - useMargins: this.props.useMargins, - hidePanelTitles: this.props.hidePanelTitles, - syncColors: this.props.syncColors, - syncCursor: this.props.syncCursor, - syncTooltips: this.props.syncTooltips, - }; - - constructor(props: Props) { - super(props); - } - - handleUseMarginsChange = (evt: any) => { - const isChecked = evt.target.checked; - this.props.onUseMarginsChange(isChecked); - this.setState({ useMargins: isChecked }); - }; - - handleHidePanelTitlesChange = (evt: any) => { - const isChecked = !evt.target.checked; - this.props.onHidePanelTitlesChange(isChecked); - this.setState({ hidePanelTitles: isChecked }); - }; - - handleSyncColorsChange = (evt: any) => { - const isChecked = evt.target.checked; - this.props.onSyncColorsChange(isChecked); - this.setState({ syncColors: isChecked }); - }; - - handleSyncCursorChange = (evt: any) => { - const isChecked = evt.target.checked; - this.props.onSyncCursorChange(isChecked); - this.setState({ syncCursor: isChecked }); - }; - - handleSyncTooltipsChange = (evt: any) => { - const isChecked = evt.target.checked; - this.props.onSyncTooltipsChange(isChecked); - this.setState({ syncTooltips: isChecked }); - }; - - render() { - return ( - - - - - - - - - - <> - - - - - - - - - - - - - ); - } -} diff --git a/src/plugins/dashboard/public/application/top_nav/show_options_popover.tsx b/src/plugins/dashboard/public/application/top_nav/show_options_popover.tsx deleted file mode 100644 index 0025fb00fa80..000000000000 --- a/src/plugins/dashboard/public/application/top_nav/show_options_popover.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; - -import { I18nProvider } from '@kbn/i18n-react'; -import { EuiWrappingPopover } from '@elastic/eui'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; - -import { OptionsMenu } from './options'; -import { pluginServices } from '../../services/plugin_services'; - -let isOpen = false; - -const container = document.createElement('div'); - -const onClose = () => { - ReactDOM.unmountComponentAtNode(container); - isOpen = false; -}; - -export interface ShowOptionsPopoverProps { - anchorElement: HTMLElement; - useMargins: boolean; - onUseMarginsChange: (useMargins: boolean) => void; - syncColors: boolean; - onSyncColorsChange: (syncColors: boolean) => void; - syncCursor: boolean; - onSyncCursorChange: (syncCursor: boolean) => void; - syncTooltips: boolean; - onSyncTooltipsChange: (syncTooltips: boolean) => void; - hidePanelTitles: boolean; - onHidePanelTitlesChange: (hideTitles: boolean) => void; -} - -export function showOptionsPopover({ - anchorElement, - useMargins, - onUseMarginsChange, - hidePanelTitles, - onHidePanelTitlesChange, - syncColors, - onSyncColorsChange, - syncCursor, - onSyncCursorChange, - syncTooltips, - onSyncTooltipsChange, -}: ShowOptionsPopoverProps) { - const { - settings: { - theme: { theme$ }, - }, - } = pluginServices.getServices(); - - if (isOpen) { - onClose(); - return; - } - - isOpen = true; - - document.body.appendChild(container); - const element = ( - - - - - - - - ); - ReactDOM.render(element, container); -} diff --git a/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts b/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts deleted file mode 100644 index 8f2f580dbdd3..000000000000 --- a/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export const TopNavIds = { - SHARE: 'share', - OPTIONS: 'options', - QUICK_SAVE: 'quickSave', - SAVE: 'save', - EXIT_EDIT_MODE: 'exitEditMode', - ENTER_EDIT_MODE: 'enterEditMode', - CLONE: 'clone', - FULL_SCREEN: 'fullScreenMode', - LABS: 'labs', -}; diff --git a/src/plugins/dashboard/public/dashboard_actions/_dashboard_actions_strings.ts b/src/plugins/dashboard/public/dashboard_actions/_dashboard_actions_strings.ts new file mode 100644 index 000000000000..5d87d72aecab --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_actions/_dashboard_actions_strings.ts @@ -0,0 +1,154 @@ +/* + * 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 dashboardCopyToDashboardActionStrings = { + getDisplayName: () => + i18n.translate('dashboard.panel.copyToDashboard.title', { + defaultMessage: 'Copy to dashboard', + }), + getCancelButtonName: () => + i18n.translate('dashboard.panel.copyToDashboard.cancel', { + defaultMessage: 'Cancel', + }), + getAcceptButtonName: () => + i18n.translate('dashboard.panel.copyToDashboard.goToDashboard', { + defaultMessage: 'Copy and go to dashboard', + }), + getNewDashboardOption: () => + i18n.translate('dashboard.panel.copyToDashboard.newDashboardOptionLabel', { + defaultMessage: 'New dashboard', + }), + getExistingDashboardOption: () => + i18n.translate('dashboard.panel.copyToDashboard.existingDashboardOptionLabel', { + defaultMessage: 'Existing dashboard', + }), + getDescription: () => + i18n.translate('dashboard.panel.copyToDashboard.description', { + defaultMessage: 'Choose the destination dashboard.', + }), +}; + +export const dashboardAddToLibraryActionStrings = { + getDisplayName: () => + i18n.translate('dashboard.panel.AddToLibrary', { + defaultMessage: 'Save to library', + }), + getSuccessMessage: (panelTitle: string) => + i18n.translate('dashboard.panel.addToLibrary.successMessage', { + defaultMessage: `Panel {panelTitle} was added to the visualize library`, + values: { panelTitle }, + }), +}; + +export const dashboardClonePanelActionStrings = { + getDisplayName: () => + i18n.translate('dashboard.panel.clonePanel', { + defaultMessage: 'Clone panel', + }), + getClonedTag: () => + i18n.translate('dashboard.panel.title.clonedTag', { + defaultMessage: 'copy', + }), + getSuccessMessage: () => + i18n.translate('dashboard.panel.clonedToast', { + defaultMessage: 'Cloned panel', + }), +}; + +export const dashboardExpandPanelActionStrings = { + getMinimizeTitle: () => + i18n.translate('dashboard.actions.toggleExpandPanelMenuItem.expandedDisplayName', { + defaultMessage: 'Minimize', + }), + getMaximizeTitle: () => + i18n.translate('dashboard.actions.toggleExpandPanelMenuItem.notExpandedDisplayName', { + defaultMessage: 'Maximize panel', + }), +}; + +export const dashboardExportCsvActionStrings = { + getDisplayName: () => + i18n.translate('dashboard.actions.DownloadCreateDrilldownAction.displayName', { + defaultMessage: 'Download as CSV', + }), + getUntitledFilename: () => + i18n.translate('dashboard.actions.downloadOptionsUnsavedFilename', { + defaultMessage: 'untitled', + }), +}; + +export const dashboardUnlinkFromLibraryActionStrings = { + getDisplayName: () => + i18n.translate('dashboard.panel.unlinkFromLibrary', { + defaultMessage: 'Unlink from library', + }), + getSuccessMessage: (panelTitle: string) => + i18n.translate('dashboard.panel.unlinkFromLibrary.successMessage', { + defaultMessage: `Panel {panelTitle} is no longer connected to the visualize library`, + values: { panelTitle }, + }), +}; + +export const dashboardLibraryNotificationStrings = { + getDisplayName: () => + i18n.translate('dashboard.panel.LibraryNotification', { + defaultMessage: 'Visualize Library Notification', + }), + getTooltip: () => + i18n.translate('dashboard.panel.libraryNotification.toolTip', { + defaultMessage: + 'Editing this panel might affect other dashboards. To change this panel only, unlink it from the library.', + }), + getPopoverAriaLabel: () => + i18n.translate('dashboard.panel.libraryNotification.ariaLabel', { + defaultMessage: 'View library information and unlink this panel', + }), +}; + +export const dashboardReplacePanelActionStrings = { + getDisplayName: () => + i18n.translate('dashboard.panel.removePanel.replacePanel', { + defaultMessage: 'Replace panel', + }), + getSuccessMessage: (savedObjectName: string) => + i18n.translate('dashboard.addPanel.savedObjectAddedToContainerSuccessMessageTitle', { + defaultMessage: '{savedObjectName} was added', + values: { + savedObjectName, + }, + }), + getNoMatchingObjectsMessage: () => + i18n.translate('dashboard.addPanel.noMatchingObjectsMessage', { + defaultMessage: 'No matching objects found.', + }), +}; + +export const dashboardFilterNotificationActionStrings = { + getDisplayName: () => + i18n.translate('dashboard.panel.filters', { + defaultMessage: 'Panel filters', + }), + getEditButtonTitle: () => + i18n.translate('dashboard.panel.filters.modal.editButton', { + defaultMessage: 'Edit filters', + }), + getCloseButtonTitle: () => + i18n.translate('dashboard.panel.filters.modal.closeButton', { + defaultMessage: 'Close', + }), + getQueryTitle: () => + i18n.translate('dashboard.panel.filters.modal.queryTitle', { + defaultMessage: 'Query', + }), + getFiltersTitle: () => + i18n.translate('dashboard.panel.filters.modal.filtersTitle', { + defaultMessage: 'Filters', + }), +}; diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/add_to_library_action.test.tsx similarity index 94% rename from src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx rename to src/plugins/dashboard/public/dashboard_actions/add_to_library_action.test.tsx index aa3419e37890..1e9f0e7f02b9 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/add_to_library_action.test.tsx @@ -5,11 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -import { DashboardContainer } from '../embeddable/dashboard_container'; -import { getSampleDashboardInput } from '../test_helpers'; -import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; - import { EmbeddableInput, ErrorEmbeddable, @@ -25,8 +20,12 @@ import { ContactCardEmbeddableOutput, CONTACT_CARD_EMBEDDABLE, } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; -import { pluginServices } from '../../services/plugin_services'; +import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; + +import { getSampleDashboardInput } from '../mocks'; +import { pluginServices } from '../services/plugin_services'; import { AddToLibraryAction } from './add_to_library_action'; +import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container'; const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); pluginServices.getServices().embeddable.getEmbeddableFactory = jest @@ -50,6 +49,7 @@ beforeEach(async () => { pluginServices.getServices().application.capabilities = defaultCapabilities; container = new DashboardContainer(getSampleDashboardInput()); + await container.untilInitialized(); const contactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, @@ -169,6 +169,6 @@ test('Add to library returns reference type input', async () => { expect(newPanelId).toBeDefined(); const newPanel = container.getInput().panels[newPanelId!]; expect(newPanel.type).toEqual(embeddable.type); - expect(newPanel.explicitInput.attributes).toBeUndefined(); + expect((newPanel.explicitInput as unknown as { attributes: unknown }).attributes).toBeUndefined(); expect(newPanel.explicitInput.savedObjectId).toBe('testSavedObjectId'); }); diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx b/src/plugins/dashboard/public/dashboard_actions/add_to_library_action.tsx similarity index 88% rename from src/plugins/dashboard/public/application/actions/add_to_library_action.tsx rename to src/plugins/dashboard/public/dashboard_actions/add_to_library_action.tsx index 0510d35519ff..165f77b2c177 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/add_to_library_action.tsx @@ -10,17 +10,17 @@ import { ViewMode, type PanelState, type IEmbeddable, + isErrorEmbeddable, PanelNotFoundError, type EmbeddableInput, - isErrorEmbeddable, isReferenceOrValueEmbeddable, } from '@kbn/embeddable-plugin/public'; import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; -import { dashboardAddToLibraryAction } from '../../dashboard_strings'; -import { type DashboardPanelState, type DashboardContainer } from '..'; -import { pluginServices } from '../../services/plugin_services'; -import { DASHBOARD_CONTAINER_TYPE } from '../../dashboard_constants'; +import { DashboardPanelState } from '../../common'; +import { pluginServices } from '../services/plugin_services'; +import { dashboardAddToLibraryActionStrings } from './_dashboard_actions_strings'; +import { DASHBOARD_CONTAINER_TYPE, type DashboardContainer } from '../dashboard_container'; export const ACTION_ADD_TO_LIBRARY = 'saveToLibrary'; @@ -47,7 +47,7 @@ export class AddToLibraryAction implements Action { if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { throw new IncompatibleActionError(); } - return dashboardAddToLibraryAction.getDisplayName(); + return dashboardAddToLibraryActionStrings.getDisplayName(); } public getIconType({ embeddable }: AddToLibraryActionContext) { @@ -94,7 +94,7 @@ export class AddToLibraryAction implements Action { }; dashboard.replacePanel(panelToReplace, newPanel, true); - const title = dashboardAddToLibraryAction.getSuccessMessage( + const title = dashboardAddToLibraryActionStrings.getSuccessMessage( embeddable.getTitle() ? `'${embeddable.getTitle()}'` : '' ); this.toastsService.addSuccess({ diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.test.tsx similarity index 96% rename from src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx rename to src/plugins/dashboard/public/dashboard_actions/clone_panel_action.test.tsx index 0fb63049ebe3..d59c2047eeb2 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.test.tsx @@ -6,14 +6,6 @@ * Side Public License, v 1. */ -import { DashboardPanelState } from '../embeddable'; -import { DashboardContainer } from '../embeddable/dashboard_container'; -import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; - -import { coreMock } from '@kbn/core/public/mocks'; -import { CoreStart } from '@kbn/core/public'; -import { ClonePanelAction } from './clone_panel_action'; -import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; import { ContactCardEmbeddable, ContactCardEmbeddableFactory, @@ -21,8 +13,16 @@ import { ContactCardEmbeddableOutput, CONTACT_CARD_EMBEDDABLE, } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; +import { CoreStart } from '@kbn/core/public'; +import { coreMock } from '@kbn/core/public/mocks'; +import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; import { ErrorEmbeddable, IContainer, isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; -import { pluginServices } from '../../services/plugin_services'; + +import { DashboardPanelState } from '../../common'; +import { ClonePanelAction } from './clone_panel_action'; +import { pluginServices } from '../services/plugin_services'; +import { getSampleDashboardInput, getSampleDashboardPanel } from '../mocks'; +import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container'; let container: DashboardContainer; let byRefOrValEmbeddable: ContactCardEmbeddable; @@ -46,10 +46,12 @@ beforeEach(async () => { }, }); const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); + pluginServices.getServices().embeddable.getEmbeddableFactory = jest .fn() .mockReturnValue(mockEmbeddableFactory); container = new DashboardContainer(input); + await container.untilInitialized(); const refOrValContactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, @@ -88,11 +90,7 @@ beforeEach(async () => { test('Clone is incompatible with Error Embeddables', async () => { const action = new ClonePanelAction(coreStart.savedObjects); - const errorEmbeddable = new ErrorEmbeddable( - 'Wow what an awful error', - { id: ' 404' }, - byRefOrValEmbeddable.getRoot() as IContainer - ); + const errorEmbeddable = new ErrorEmbeddable('Wow what an awful error', { id: ' 404' }, container); expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false); }); diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx similarity index 92% rename from src/plugins/dashboard/public/application/actions/clone_panel_action.tsx rename to src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx index 11a96733337f..4e265187ea3a 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/clone_panel_action.tsx @@ -26,11 +26,11 @@ import type { SavedObject } from '@kbn/saved-objects-plugin/public'; import { placePanelBeside, IPanelPlacementBesideArgs, -} from '../embeddable/panel/dashboard_panel_placement'; -import { pluginServices } from '../../services/plugin_services'; -import { dashboardClonePanelAction } from '../../dashboard_strings'; -import { DASHBOARD_CONTAINER_TYPE } from '../../dashboard_constants'; -import { type DashboardPanelState, type DashboardContainer } from '..'; +} from '../dashboard_container/component/panel/dashboard_panel_placement'; +import { type DashboardPanelState } from '../../common'; +import { pluginServices } from '../services/plugin_services'; +import { dashboardClonePanelActionStrings } from './_dashboard_actions_strings'; +import { DASHBOARD_CONTAINER_TYPE, type DashboardContainer } from '../dashboard_container'; export const ACTION_CLONE_PANEL = 'clonePanel'; @@ -55,7 +55,7 @@ export class ClonePanelAction implements Action { if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { throw new IncompatibleActionError(); } - return dashboardClonePanelAction.getDisplayName(); + return dashboardClonePanelActionStrings.getDisplayName(); } public getIconType({ embeddable }: ClonePanelActionContext) { @@ -102,7 +102,7 @@ export class ClonePanelAction implements Action { private async getCloneTitle(embeddable: IEmbeddable, rawTitle: string) { if (rawTitle === '') return ''; // If - const clonedTag = dashboardClonePanelAction.getClonedTag(); + const clonedTag = dashboardClonePanelActionStrings.getClonedTag(); const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g'); const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g'); const baseTitle = rawTitle.replace(cloneNumberRegex, '').replace(cloneRegex, '').trim(); @@ -201,7 +201,7 @@ export class ClonePanelAction implements Action { } } this.toastsService.addSuccess({ - title: dashboardClonePanelAction.getSuccessMessage(), + title: dashboardClonePanelActionStrings.getSuccessMessage(), 'data-test-subj': 'addObjectToContainerSuccess', }); return panelState; diff --git a/src/plugins/dashboard/public/application/actions/copy_to_dashboard_action.tsx b/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_action.tsx similarity index 90% rename from src/plugins/dashboard/public/application/actions/copy_to_dashboard_action.tsx rename to src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_action.tsx index cdd8d726e9fa..577ac2dc7a18 100644 --- a/src/plugins/dashboard/public/application/actions/copy_to_dashboard_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_action.tsx @@ -9,15 +9,14 @@ import React from 'react'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; -import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; import type { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; import type { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; -import { dashboardCopyToDashboardAction } from '../../dashboard_strings'; -import { DashboardContainer } from '../embeddable'; +import { pluginServices } from '../services/plugin_services'; import { CopyToDashboardModal } from './copy_to_dashboard_modal'; -import { pluginServices } from '../../services/plugin_services'; -import { DASHBOARD_CONTAINER_TYPE } from '../../dashboard_constants'; +import { dashboardCopyToDashboardActionStrings } from './_dashboard_actions_strings'; +import { DashboardContainer, DASHBOARD_CONTAINER_TYPE } from '../dashboard_container'; export const ACTION_COPY_TO_DASHBOARD = 'copyToDashboard'; @@ -58,7 +57,7 @@ export class CopyToDashboardAction implements Action session.close()} - dashboardId={(embeddable.parent as DashboardContainer).getInput().id} + dashboardId={(embeddable.parent as DashboardContainer).getDashboardSavedObjectId()} embeddable={embeddable} />, { theme$: this.theme$ } diff --git a/src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx b/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx similarity index 89% rename from src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx rename to src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx index af91631d20b3..dbabf2f1ee76 100644 --- a/src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx @@ -7,27 +7,30 @@ */ import React, { useCallback, useState } from 'react'; import { omit } from 'lodash'; + import { + EuiText, + EuiRadio, + EuiPanel, EuiButton, - EuiButtonEmpty, + EuiSpacer, EuiFormRow, + EuiFocusTrap, EuiModalBody, + EuiButtonEmpty, EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiPanel, - EuiRadio, - EuiSpacer, - EuiText, - EuiFocusTrap, EuiOutsideClickDetector, } from '@elastic/eui'; import { IEmbeddable, PanelNotFoundError } from '@kbn/embeddable-plugin/public'; import { LazyDashboardPicker, withSuspense } from '@kbn/presentation-util-plugin/public'; -import { dashboardCopyToDashboardAction } from '../../dashboard_strings'; -import { createDashboardEditUrl, DashboardConstants } from '../..'; -import { type DashboardContainer, DashboardPanelState } from '..'; -import { pluginServices } from '../../services/plugin_services'; + +import { DashboardPanelState } from '../../common'; +import { pluginServices } from '../services/plugin_services'; +import { type DashboardContainer } from '../dashboard_container'; +import { dashboardCopyToDashboardActionStrings } from './_dashboard_actions_strings'; +import { createDashboardEditUrl, CREATE_NEW_DASHBOARD_URL } from '../dashboard_constants'; interface CopyToDashboardModalProps { PresentationUtilContext: React.FC; @@ -72,7 +75,7 @@ export function CopyToDashboardModal({ const path = dashboardOption === 'existing' && selectedDashboard ? `#${createDashboardEditUrl(selectedDashboard.id, true)}` - : `#${DashboardConstants.CREATE_NEW_DASHBOARD_URL}`; + : `#${CREATE_NEW_DASHBOARD_URL}`; closeModal(); stateTransfer.navigateToWithEmbeddablePackage('dashboards', { @@ -96,14 +99,14 @@ export function CopyToDashboardModal({ -

{dashboardCopyToDashboardAction.getDisplayName()}

+

{dashboardCopyToDashboardActionStrings.getDisplayName()}

<> -

{dashboardCopyToDashboardAction.getDescription()}

+

{dashboardCopyToDashboardActionStrings.getDescription()}

@@ -120,7 +123,7 @@ export function CopyToDashboardModal({ data-test-subj="add-to-existing-dashboard-option" id="existing-dashboard-option" name="dashboard-option" - label={dashboardCopyToDashboardAction.getExistingDashboardOption()} + label={dashboardCopyToDashboardActionStrings.getExistingDashboardOption()} onChange={() => setDashboardOption('existing')} />
@@ -141,7 +144,7 @@ export function CopyToDashboardModal({ id="new-dashboard-option" name="dashboard-option" disabled={!dashboardId} - label={dashboardCopyToDashboardAction.getNewDashboardOption()} + label={dashboardCopyToDashboardActionStrings.getNewDashboardOption()} onChange={() => setDashboardOption('new')} /> @@ -155,7 +158,7 @@ export function CopyToDashboardModal({ closeModal()}> - {dashboardCopyToDashboardAction.getCancelButtonName()} + {dashboardCopyToDashboardActionStrings.getCancelButtonName()} - {dashboardCopyToDashboardAction.getAcceptButtonName()} + {dashboardCopyToDashboardActionStrings.getAcceptButtonName()} diff --git a/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.test.tsx similarity index 89% rename from src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx rename to src/plugins/dashboard/public/dashboard_actions/expand_panel_action.test.tsx index dd28d0060c32..44a1b2d4828d 100644 --- a/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.test.tsx @@ -7,8 +7,8 @@ */ import { ExpandPanelAction } from './expand_panel_action'; -import { DashboardContainer } from '../embeddable/dashboard_container'; -import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; +import { getSampleDashboardInput, getSampleDashboardPanel } from '../mocks'; +import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container'; import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; import { @@ -19,7 +19,7 @@ import { CONTACT_CARD_EMBEDDABLE, } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; -import { pluginServices } from '../../services/plugin_services'; +import { pluginServices } from '../services/plugin_services'; let container: DashboardContainer; let embeddable: ContactCardEmbeddable; @@ -40,6 +40,7 @@ beforeEach(async () => { }); container = new DashboardContainer(input); + await container.untilInitialized(); const contactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, @@ -59,11 +60,11 @@ beforeEach(async () => { test('Sets the embeddable expanded panel id on the parent', async () => { const expandPanelAction = new ExpandPanelAction(); - expect(container.getInput().expandedPanelId).toBeUndefined(); + expect(container.getExpandedPanelId()).toBeUndefined(); expandPanelAction.execute({ embeddable }); - expect(container.getInput().expandedPanelId).toBe(embeddable.id); + expect(container.getExpandedPanelId()).toBe(embeddable.id); }); test('Is not compatible when embeddable is not in a dashboard container', async () => { diff --git a/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx b/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.tsx similarity index 75% rename from src/plugins/dashboard/public/application/actions/expand_panel_action.tsx rename to src/plugins/dashboard/public/dashboard_actions/expand_panel_action.tsx index 79ab109ddce5..0d3dd592dcc3 100644 --- a/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/expand_panel_action.tsx @@ -9,9 +9,8 @@ import type { IEmbeddable } from '@kbn/embeddable-plugin/public'; import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; -import { DashboardContainerInput, DASHBOARD_CONTAINER_TYPE } from '../..'; -import { dashboardExpandPanelAction } from '../../dashboard_strings'; -import { type DashboardContainer } from '../embeddable'; +import { DASHBOARD_CONTAINER_TYPE, type DashboardContainer } from '../dashboard_container'; +import { dashboardExpandPanelActionStrings } from './_dashboard_actions_strings'; export const ACTION_EXPAND_PANEL = 'togglePanel'; @@ -24,9 +23,7 @@ function isExpanded(embeddable: IEmbeddable) { throw new IncompatibleActionError(); } - return ( - embeddable.id === (embeddable.parent.getInput() as DashboardContainerInput).expandedPanelId - ); + return embeddable.id === (embeddable.parent as DashboardContainer).getExpandedPanelId(); } export interface ExpandPanelActionContext { @@ -46,16 +43,15 @@ export class ExpandPanelAction implements Action { } return isExpanded(embeddable) - ? dashboardExpandPanelAction.getMinimizeTitle() - : dashboardExpandPanelAction.getMaximizeTitle(); + ? dashboardExpandPanelActionStrings.getMinimizeTitle() + : dashboardExpandPanelActionStrings.getMaximizeTitle(); } public getIconType({ embeddable }: ExpandPanelActionContext) { if (!embeddable.parent || !isDashboard(embeddable.parent)) { throw new IncompatibleActionError(); } - // TODO: use 'minimize' when an eui-icon of such is available. - return isExpanded(embeddable) ? 'expand' : 'expand'; + return isExpanded(embeddable) ? 'minimize' : 'expand'; } public async isCompatible({ embeddable }: ExpandPanelActionContext) { @@ -67,8 +63,6 @@ export class ExpandPanelAction implements Action { throw new IncompatibleActionError(); } const newValue = isExpanded(embeddable) ? undefined : embeddable.id; - embeddable.parent.updateInput({ - expandedPanelId: newValue, - }); + (embeddable.parent as DashboardContainer).setExpandedPanelId(newValue); } } diff --git a/src/plugins/dashboard/public/application/actions/export_csv_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/export_csv_action.test.tsx similarity index 94% rename from src/plugins/dashboard/public/application/actions/export_csv_action.test.tsx rename to src/plugins/dashboard/public/dashboard_actions/export_csv_action.test.tsx index ec63c3e0ec7d..265d34992689 100644 --- a/src/plugins/dashboard/public/application/actions/export_csv_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/export_csv_action.test.tsx @@ -6,11 +6,6 @@ * Side Public License, v 1. */ -import { CoreStart } from '@kbn/core/public'; -import { isErrorEmbeddable, IContainer, ErrorEmbeddable } from '@kbn/embeddable-plugin/public'; - -import { DashboardContainer } from '../embeddable/dashboard_container'; -import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; import { ContactCardEmbeddable, ContactCardEmbeddableInput, @@ -18,10 +13,15 @@ import { ContactCardExportableEmbeddableFactory, CONTACT_CARD_EXPORTABLE_EMBEDDABLE, } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; +import { CoreStart } from '@kbn/core/public'; import { coreMock } from '@kbn/core/public/mocks'; -import { ExportCSVAction } from './export_csv_action'; import { LINE_FEED_CHARACTER } from '@kbn/data-plugin/common/exports/export_csv'; -import { pluginServices } from '../../services/plugin_services'; +import { isErrorEmbeddable, IContainer, ErrorEmbeddable } from '@kbn/embeddable-plugin/public'; + +import { ExportCSVAction } from './export_csv_action'; +import { pluginServices } from '../services/plugin_services'; +import { getSampleDashboardInput, getSampleDashboardPanel } from '../mocks'; +import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container'; describe('Export CSV action', () => { let container: DashboardContainer; @@ -54,6 +54,7 @@ describe('Export CSV action', () => { }, }); container = new DashboardContainer(input); + await container.untilInitialized(); const contactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, diff --git a/src/plugins/dashboard/public/application/actions/export_csv_action.tsx b/src/plugins/dashboard/public/dashboard_actions/export_csv_action.tsx similarity index 92% rename from src/plugins/dashboard/public/application/actions/export_csv_action.tsx rename to src/plugins/dashboard/public/dashboard_actions/export_csv_action.tsx index fa90da055005..11ef135c7657 100644 --- a/src/plugins/dashboard/public/application/actions/export_csv_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/export_csv_action.tsx @@ -13,8 +13,8 @@ import { downloadMultipleAs } from '@kbn/share-plugin/public'; import { FormatFactory } from '@kbn/field-formats-plugin/common'; import type { Adapters, IEmbeddable } from '@kbn/embeddable-plugin/public'; -import { dashboardExportCsvAction } from '../../dashboard_strings'; -import { pluginServices } from '../../services/plugin_services'; +import { dashboardExportCsvActionStrings } from './_dashboard_actions_strings'; +import { pluginServices } from '../services/plugin_services'; export const ACTION_EXPORT_CSV = 'ACTION_EXPORT_CSV'; @@ -48,7 +48,7 @@ export class ExportCSVAction implements Action { } public readonly getDisplayName = (context: ExportContext): string => - dashboardExportCsvAction.getDisplayName(); + dashboardExportCsvActionStrings.getDisplayName(); public async isCompatible(context: ExportContext): Promise { return !!this.hasDatatableContent(context.embeddable?.getInspectorAdapters?.()); @@ -89,7 +89,7 @@ export class ExportCSVAction implements Action { // skip empty datatables if (datatable) { const postFix = datatables.length > 1 ? `-${i + 1}` : ''; - const untitledFilename = dashboardExportCsvAction.getUntitledFilename(); + const untitledFilename = dashboardExportCsvActionStrings.getUntitledFilename(); memo[`${context!.embeddable!.getTitle() || untitledFilename}${postFix}.csv`] = { content: exporters.datatableToCSV(datatable, { diff --git a/src/plugins/dashboard/public/application/actions/filters_notification_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/filters_notification_action.test.tsx similarity index 93% rename from src/plugins/dashboard/public/application/actions/filters_notification_action.test.tsx rename to src/plugins/dashboard/public/dashboard_actions/filters_notification_action.test.tsx index be9dc25f69fb..e864e35d5ad3 100644 --- a/src/plugins/dashboard/public/application/actions/filters_notification_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/filters_notification_action.test.tsx @@ -17,9 +17,9 @@ import { import { type Query, type AggregateQuery, Filter } from '@kbn/es-query'; import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; -import { getSampleDashboardInput } from '../test_helpers'; -import { pluginServices } from '../../services/plugin_services'; -import { DashboardContainer } from '../embeddable/dashboard_container'; +import { getSampleDashboardInput } from '../mocks'; +import { pluginServices } from '../services/plugin_services'; +import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container'; import { FiltersNotificationAction } from './filters_notification_action'; const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); @@ -52,6 +52,7 @@ const getMockPhraseFilter = (key: string, value: string) => { const buildEmbeddable = async (input?: Partial) => { const container = new DashboardContainer(getSampleDashboardInput()); + await container.untilInitialized(); const contactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, diff --git a/src/plugins/dashboard/public/application/actions/filters_notification_action.tsx b/src/plugins/dashboard/public/dashboard_actions/filters_notification_action.tsx similarity index 94% rename from src/plugins/dashboard/public/application/actions/filters_notification_action.tsx rename to src/plugins/dashboard/public/dashboard_actions/filters_notification_action.tsx index b7ee2311ebd1..d7c6746c0c6e 100644 --- a/src/plugins/dashboard/public/application/actions/filters_notification_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/filters_notification_action.tsx @@ -18,8 +18,8 @@ import { type AggregateQuery } from '@kbn/es-query'; import { I18nProvider } from '@kbn/i18n-react'; import { FiltersNotificationPopover } from './filters_notification_popover'; -import { dashboardFilterNotificationAction } from '../../dashboard_strings'; -import { pluginServices } from '../../services/plugin_services'; +import { dashboardFilterNotificationActionStrings } from './_dashboard_actions_strings'; +import { pluginServices } from '../services/plugin_services'; export const BADGE_FILTERS_NOTIFICATION = 'ACTION_FILTERS_NOTIFICATION'; @@ -32,7 +32,7 @@ export class FiltersNotificationAction implements Action { +describe('filters notification popover', () => { const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); pluginServices.getServices().embeddable.getEmbeddableFactory = jest .fn() @@ -41,6 +41,7 @@ describe('LibraryNotificationPopover', () => { beforeEach(async () => { container = new DashboardContainer(getSampleDashboardInput()); + await container.untilInitialized(); const contactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, diff --git a/src/plugins/dashboard/public/application/actions/filters_notification_popover.tsx b/src/plugins/dashboard/public/dashboard_actions/filters_notification_popover.tsx similarity index 93% rename from src/plugins/dashboard/public/application/actions/filters_notification_popover.tsx rename to src/plugins/dashboard/public/dashboard_actions/filters_notification_popover.tsx index 974c7280f896..636c88d56347 100644 --- a/src/plugins/dashboard/public/application/actions/filters_notification_popover.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/filters_notification_popover.tsx @@ -19,7 +19,7 @@ import { } from '@elastic/eui'; import { EditPanelAction } from '@kbn/embeddable-plugin/public'; -import { dashboardFilterNotificationAction } from '../../dashboard_strings'; +import { dashboardFilterNotificationActionStrings } from './_dashboard_actions_strings'; import { FiltersNotificationActionContext } from './filters_notification_action'; import { FiltersNotificationPopoverContents } from './filters_notification_popover_contents'; @@ -73,7 +73,7 @@ export function FiltersNotificationPopover({ fill onClick={() => editPanelAction.execute({ embeddable })} > - {dashboardFilterNotificationAction.getEditButtonTitle()} + {dashboardFilterNotificationActionStrings.getEditButtonTitle()} diff --git a/src/plugins/dashboard/public/application/actions/filters_notification_popover_contents.tsx b/src/plugins/dashboard/public/dashboard_actions/filters_notification_popover_contents.tsx similarity index 90% rename from src/plugins/dashboard/public/application/actions/filters_notification_popover_contents.tsx rename to src/plugins/dashboard/public/dashboard_actions/filters_notification_popover_contents.tsx index b3c37f40d6c6..9b9b34543127 100644 --- a/src/plugins/dashboard/public/application/actions/filters_notification_popover_contents.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/filters_notification_popover_contents.tsx @@ -21,8 +21,8 @@ import { } from '@kbn/es-query'; import { FiltersNotificationActionContext } from './filters_notification_action'; -import { dashboardFilterNotificationAction } from '../../dashboard_strings'; -import { DashboardContainer } from '../embeddable'; +import { dashboardFilterNotificationActionStrings } from './_dashboard_actions_strings'; +import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container'; export interface FiltersNotificationProps { context: FiltersNotificationActionContext; @@ -72,14 +72,14 @@ export function FiltersNotificationPopoverContents({ context }: FiltersNotificat > {queryString !== '' && ( {queryString} @@ -87,7 +87,7 @@ export function FiltersNotificationPopoverContents({ context }: FiltersNotificat )} {filters && filters.length > 0 && ( - + diff --git a/src/plugins/dashboard/public/application/actions/index.ts b/src/plugins/dashboard/public/dashboard_actions/index.ts similarity index 98% rename from src/plugins/dashboard/public/application/actions/index.ts rename to src/plugins/dashboard/public/dashboard_actions/index.ts index 7793c2803754..6317c10dda1a 100644 --- a/src/plugins/dashboard/public/application/actions/index.ts +++ b/src/plugins/dashboard/public/dashboard_actions/index.ts @@ -12,7 +12,7 @@ import { getSavedObjectFinder } from '@kbn/saved-objects-plugin/public'; import { ExportCSVAction } from './export_csv_action'; import { ClonePanelAction } from './clone_panel_action'; -import { DashboardStartDependencies } from '../../plugin'; +import { DashboardStartDependencies } from '../plugin'; import { ExpandPanelAction } from './expand_panel_action'; import { ReplacePanelAction } from './replace_panel_action'; import { AddToLibraryAction } from './add_to_library_action'; diff --git a/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/library_notification_action.test.tsx similarity index 93% rename from src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx rename to src/plugins/dashboard/public/dashboard_actions/library_notification_action.test.tsx index f30cba538b8d..ed7f501f8329 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/library_notification_action.test.tsx @@ -22,11 +22,11 @@ import { } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; -import { getSampleDashboardInput } from '../test_helpers'; -import { pluginServices } from '../../services/plugin_services'; -import { DashboardContainer } from '../embeddable/dashboard_container'; +import { getSampleDashboardInput } from '../mocks'; +import { pluginServices } from '../services/plugin_services'; import { UnlinkFromLibraryAction } from './unlink_from_library_action'; import { LibraryNotificationAction } from './library_notification_action'; +import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container'; const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); pluginServices.getServices().embeddable.getEmbeddableFactory = jest @@ -44,6 +44,7 @@ beforeEach(async () => { } as unknown as UnlinkFromLibraryAction; container = new DashboardContainer(getSampleDashboardInput()); + await container.untilInitialized(); const contactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, diff --git a/src/plugins/dashboard/public/application/actions/library_notification_action.tsx b/src/plugins/dashboard/public/dashboard_actions/library_notification_action.tsx similarity index 92% rename from src/plugins/dashboard/public/application/actions/library_notification_action.tsx rename to src/plugins/dashboard/public/dashboard_actions/library_notification_action.tsx index a05b78994b31..baf3ae63b33f 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/library_notification_action.tsx @@ -14,13 +14,13 @@ import { isErrorEmbeddable, isReferenceOrValueEmbeddable, } from '@kbn/embeddable-plugin/public'; -import { KibanaThemeProvider, reactToUiComponent } from '@kbn/kibana-react-plugin/public'; import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; +import { KibanaThemeProvider, reactToUiComponent } from '@kbn/kibana-react-plugin/public'; -import { pluginServices } from '../../services/plugin_services'; +import { pluginServices } from '../services/plugin_services'; import { UnlinkFromLibraryAction } from './unlink_from_library_action'; -import { dashboardLibraryNotification } from '../../dashboard_strings'; import { LibraryNotificationPopover } from './library_notification_popover'; +import { dashboardLibraryNotificationStrings } from './_dashboard_actions_strings'; export const ACTION_LIBRARY_NOTIFICATION = 'ACTION_LIBRARY_NOTIFICATION'; @@ -43,7 +43,7 @@ export class LibraryNotificationAction implements Action { const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); @@ -38,6 +39,8 @@ describe('LibraryNotificationPopover', () => { beforeEach(async () => { container = new DashboardContainer(getSampleDashboardInput()); + await container.untilInitialized(); + const contactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, diff --git a/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx b/src/plugins/dashboard/public/dashboard_actions/library_notification_popover.tsx similarity index 90% rename from src/plugins/dashboard/public/application/actions/library_notification_popover.tsx rename to src/plugins/dashboard/public/dashboard_actions/library_notification_popover.tsx index 38c8452eadde..21dd067885ed 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/library_notification_popover.tsx @@ -18,9 +18,9 @@ import { EuiText, } from '@elastic/eui'; -import { dashboardLibraryNotification } from '../../dashboard_strings'; import { UnlinkFromLibraryAction } from './unlink_from_library_action'; import { LibraryNotificationActionContext } from './library_notification_action'; +import { dashboardLibraryNotificationStrings } from './_dashboard_actions_strings'; export interface LibraryNotificationProps { context: LibraryNotificationActionContext; @@ -48,7 +48,7 @@ export function LibraryNotificationPopover({ iconType={icon} onClick={() => setIsPopoverOpen(!isPopoverOpen)} data-test-subj={`embeddablePanelNotification-${id}`} - aria-label={dashboardLibraryNotification.getPopoverAriaLabel()} + aria-label={dashboardLibraryNotificationStrings.getPopoverAriaLabel()} /> } isOpen={isPopoverOpen} @@ -58,7 +58,7 @@ export function LibraryNotificationPopover({ {displayName}
-

{dashboardLibraryNotification.getTooltip()}

+

{dashboardLibraryNotificationStrings.getTooltip()}

diff --git a/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx b/src/plugins/dashboard/public/dashboard_actions/open_replace_panel_flyout.tsx similarity index 95% rename from src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx rename to src/plugins/dashboard/public/dashboard_actions/open_replace_panel_flyout.tsx index 5322e56831ff..c4e81a17bb76 100644 --- a/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/open_replace_panel_flyout.tsx @@ -17,7 +17,7 @@ import type { import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { ReplacePanelFlyout } from './replace_panel_flyout'; -import { pluginServices } from '../../services/plugin_services'; +import { pluginServices } from '../services/plugin_services'; export async function openReplacePanelFlyout(options: { embeddable: IContainer; diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx similarity index 93% rename from src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx rename to src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx index 0a035d06d4fd..c5695b55072d 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx @@ -6,11 +6,6 @@ * Side Public License, v 1. */ -import { ReplacePanelAction } from './replace_panel_action'; -import { DashboardContainer } from '../embeddable/dashboard_container'; -import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; - -import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; import { ContactCardEmbeddable, ContactCardEmbeddableFactory, @@ -18,8 +13,12 @@ import { ContactCardEmbeddableOutput, CONTACT_CARD_EMBEDDABLE, } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; +import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; -import { pluginServices } from '../../services/plugin_services'; +import { ReplacePanelAction } from './replace_panel_action'; +import { pluginServices } from '../services/plugin_services'; +import { getSampleDashboardInput, getSampleDashboardPanel } from '../mocks'; +import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container'; const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); pluginServices.getServices().embeddable.getEmbeddableFactory = jest @@ -38,6 +37,7 @@ beforeEach(async () => { }, }); container = new DashboardContainer(input); + await container.untilInitialized(); const contactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx b/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.tsx similarity index 89% rename from src/plugins/dashboard/public/application/actions/replace_panel_action.tsx rename to src/plugins/dashboard/public/dashboard_actions/replace_panel_action.tsx index 52f6a345a181..d377be059a13 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.tsx @@ -8,10 +8,10 @@ import { type IEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; -import type { DashboardContainer } from '../embeddable'; + import { openReplacePanelFlyout } from './open_replace_panel_flyout'; -import { dashboardReplacePanelAction } from '../../dashboard_strings'; -import { DASHBOARD_CONTAINER_TYPE } from '../../dashboard_constants'; +import { dashboardReplacePanelActionStrings } from './_dashboard_actions_strings'; +import { type DashboardContainer, DASHBOARD_CONTAINER_TYPE } from '../dashboard_container'; export const ACTION_REPLACE_PANEL = 'replacePanel'; @@ -34,7 +34,7 @@ export class ReplacePanelAction implements Action { if (!embeddable.parent || !isDashboard(embeddable.parent)) { throw new IncompatibleActionError(); } - return dashboardReplacePanelAction.getDisplayName(); + return dashboardReplacePanelActionStrings.getDisplayName(); } public getIconType({ embeddable }: ReplacePanelActionContext) { diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_flyout.tsx b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx similarity index 90% rename from src/plugins/dashboard/public/application/actions/replace_panel_flyout.tsx rename to src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx index 6369ff82b821..d5d5c439a474 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/replace_panel_flyout.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; -import { Toast } from '@kbn/core/public'; + import { EmbeddableInput, EmbeddableOutput, @@ -16,9 +16,11 @@ import { IEmbeddable, SavedObjectEmbeddableInput, } from '@kbn/embeddable-plugin/public'; -import { DashboardPanelState } from '../embeddable'; -import { dashboardReplacePanelAction } from '../../dashboard_strings'; -import { pluginServices } from '../../services/plugin_services'; +import { Toast } from '@kbn/core/public'; + +import { DashboardPanelState } from '../../common'; +import { pluginServices } from '../services/plugin_services'; +import { dashboardReplacePanelActionStrings } from './_dashboard_actions_strings'; interface Props { container: IContainer; @@ -48,7 +50,7 @@ export class ReplacePanelFlyout extends React.Component { } this.lastToast = toasts.addSuccess({ - title: dashboardReplacePanelAction.getSuccessMessage(name), + title: dashboardReplacePanelActionStrings.getSuccessMessage(name), 'data-test-subj': 'addObjectToContainerSuccess', }); }; @@ -93,7 +95,7 @@ export class ReplacePanelFlyout extends React.Component { const SavedObjectFinder = this.props.savedObjectsFinder; const savedObjectsFinder = ( diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/unlink_from_library_action.test.tsx similarity index 91% rename from src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx rename to src/plugins/dashboard/public/dashboard_actions/unlink_from_library_action.test.tsx index 080c358a86fd..a0e557ce107a 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/unlink_from_library_action.test.tsx @@ -14,7 +14,6 @@ import { ReferenceOrValueEmbeddable, SavedObjectEmbeddableInput, } from '@kbn/embeddable-plugin/public'; -import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; import { ContactCardEmbeddable, ContactCardEmbeddableFactory, @@ -22,11 +21,13 @@ import { ContactCardEmbeddableOutput, CONTACT_CARD_EMBEDDABLE, } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; +import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; -import { getSampleDashboardInput } from '../test_helpers'; -import { pluginServices } from '../../services/plugin_services'; +import { getSampleDashboardInput } from '../mocks'; +import { DashboardPanelState } from '../../common'; +import { pluginServices } from '../services/plugin_services'; import { UnlinkFromLibraryAction } from './unlink_from_library_action'; -import { DashboardContainer } from '../embeddable/dashboard_container'; +import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container'; let container: DashboardContainer; let embeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable; @@ -38,6 +39,7 @@ pluginServices.getServices().embeddable.getEmbeddableFactory = jest beforeEach(async () => { container = new DashboardContainer(getSampleDashboardInput()); + await container.untilInitialized(); const contactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, @@ -148,7 +150,11 @@ test('Unlink unwraps all attributes from savedObject', async () => { (key) => !originalPanelKeySet.has(key) ); expect(newPanelId).toBeDefined(); - const newPanel = container.getInput().panels[newPanelId!]; + const newPanel = container.getInput().panels[newPanelId!] as DashboardPanelState & { + explicitInput: { attributes: unknown }; + }; expect(newPanel.type).toEqual(embeddable.type); - expect(newPanel.explicitInput.attributes).toEqual(complicatedAttributes); + expect((newPanel.explicitInput as { attributes: unknown }).attributes).toEqual( + complicatedAttributes + ); }); diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx b/src/plugins/dashboard/public/dashboard_actions/unlink_from_library_action.tsx similarity index 86% rename from src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx rename to src/plugins/dashboard/public/dashboard_actions/unlink_from_library_action.tsx index b7c53a78becc..1dafc89972a3 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/unlink_from_library_action.tsx @@ -16,10 +16,11 @@ import { isReferenceOrValueEmbeddable, } from '@kbn/embeddable-plugin/public'; import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; -import { dashboardUnlinkFromLibraryAction } from '../../dashboard_strings'; -import { type DashboardPanelState, type DashboardContainer } from '..'; -import { pluginServices } from '../../services/plugin_services'; -import { DASHBOARD_CONTAINER_TYPE } from '../../dashboard_constants'; + +import { DashboardPanelState } from '../../common'; +import { pluginServices } from '../services/plugin_services'; +import { dashboardUnlinkFromLibraryActionStrings } from './_dashboard_actions_strings'; +import { type DashboardContainer, DASHBOARD_CONTAINER_TYPE } from '../dashboard_container'; export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary'; @@ -44,7 +45,7 @@ export class UnlinkFromLibraryAction implements Action + i18n.translate('dashboard.dashboardPageTitle', { + defaultMessage: 'Dashboards', + }); + +export const dashboardReadonlyBadge = { + getText: () => + i18n.translate('dashboard.badge.readOnly.text', { + defaultMessage: 'Read only', + }), + getTooltip: () => + i18n.translate('dashboard.badge.readOnly.tooltip', { + defaultMessage: 'Unable to save dashboards', + }), +}; + +/** + * @param title {string} the current title of the dashboard + * @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title. + * @returns {string} A title to display to the user based on the above parameters. + */ +export function getDashboardTitle(title: string, viewMode: ViewMode, isNew: boolean): string { + const isEditMode = viewMode === ViewMode.EDIT; + const dashboardTitle = isNew ? getNewDashboardTitle() : title; + return isEditMode + ? i18n.translate('dashboard.strings.dashboardEditTitle', { + defaultMessage: 'Editing {title}', + values: { title: dashboardTitle }, + }) + : dashboardTitle; +} + +export const unsavedChangesBadgeStrings = { + getUnsavedChangedBadgeText: () => + i18n.translate('dashboard.unsavedChangesBadge', { + defaultMessage: 'Unsaved changes', + }), +}; + +export const leaveConfirmStrings = { + getLeaveTitle: () => + i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesTitle', { + defaultMessage: 'Unsaved changes', + }), + getLeaveSubtitle: () => + i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesSubtitle', { + defaultMessage: 'Leave Dashboard with unsaved work?', + }), + getLeaveCancelButtonText: () => + i18n.translate('dashboard.appLeaveConfirmModal.cancelButtonLabel', { + defaultMessage: 'Cancel', + }), +}; + +export const getCreateVisualizationButtonTitle = () => + i18n.translate('dashboard.solutionToolbar.addPanelButtonLabel', { + defaultMessage: 'Create visualization', + }); + +export const getNewDashboardTitle = () => + i18n.translate('dashboard.savedDashboard.newDashboardTitle', { + defaultMessage: 'New Dashboard', + }); + +export const getPanelAddedSuccessString = (savedObjectName: string) => + i18n.translate('dashboard.addPanel.newEmbeddableAddedSuccessMessageTitle', { + defaultMessage: '{savedObjectName} was added', + values: { + savedObjectName, + }, + }); + +export const getDashboardURL404String = () => + i18n.translate('dashboard.loadingError.dashboardNotFound', { + defaultMessage: 'The requested dashboard could not be found.', + }); + +export const getPanelTooOldErrorString = () => + i18n.translate('dashboard.loadURLError.PanelTooOld', { + defaultMessage: 'Cannot load panels from a URL created in a version older than 7.3', + }); + +/* + Dashboard Listing Page +*/ +export const discardConfirmStrings = { + getDiscardTitle: () => + i18n.translate('dashboard.discardChangesConfirmModal.discardChangesTitle', { + defaultMessage: 'Discard changes to dashboard?', + }), + getDiscardSubtitle: () => + i18n.translate('dashboard.discardChangesConfirmModal.discardChangesDescription', { + defaultMessage: `Once you discard your changes, there's no getting them back.`, + }), + getDiscardConfirmButtonText: () => + i18n.translate('dashboard.discardChangesConfirmModal.confirmButtonLabel', { + defaultMessage: 'Discard changes', + }), + getDiscardCancelButtonText: () => + i18n.translate('dashboard.discardChangesConfirmModal.cancelButtonLabel', { + defaultMessage: 'Cancel', + }), +}; + +export const createConfirmStrings = { + getCreateTitle: () => + i18n.translate('dashboard.createConfirmModal.unsavedChangesTitle', { + defaultMessage: 'New dashboard already in progress', + }), + getCreateSubtitle: () => + i18n.translate('dashboard.createConfirmModal.unsavedChangesSubtitle', { + defaultMessage: 'Continue editing or start over with a blank dashboard.', + }), + getStartOverButtonText: () => + i18n.translate('dashboard.createConfirmModal.confirmButtonLabel', { + defaultMessage: 'Start over', + }), + getContinueButtonText: () => + i18n.translate('dashboard.createConfirmModal.continueButtonLabel', { + defaultMessage: 'Continue editing', + }), + getCancelButtonText: () => + i18n.translate('dashboard.createConfirmModal.cancelButtonLabel', { + defaultMessage: 'Cancel', + }), +}; + +export const dashboardListingErrorStrings = { + getErrorDeletingDashboardToast: () => + i18n.translate('dashboard.deleteError.toastDescription', { + defaultMessage: 'Error encountered while deleting dashboard', + }), +}; + +export const dashboardListingTableStrings = { + getEntityName: () => + i18n.translate('dashboard.listing.table.entityName', { + defaultMessage: 'dashboard', + }), + getEntityNamePlural: () => + i18n.translate('dashboard.listing.table.entityNamePlural', { + defaultMessage: 'dashboards', + }), + getTableListTitle: () => getDashboardPageTitle(), +}; + +export const noItemsStrings = { + getReadonlyTitle: () => + i18n.translate('dashboard.listing.readonlyNoItemsTitle', { + defaultMessage: 'No dashboards to view', + }), + getReadonlyBody: () => + i18n.translate('dashboard.listing.readonlyNoItemsBody', { + defaultMessage: `There are no available dashboards. To change your permissions to view the dashboards in this space, contact your administrator.`, + }), + getReadEditTitle: () => + i18n.translate('dashboard.listing.createNewDashboard.title', { + defaultMessage: 'Create your first dashboard', + }), + getReadEditInProgressTitle: () => + i18n.translate('dashboard.listing.createNewDashboard.inProgressTitle', { + defaultMessage: 'Dashboard in progress', + }), + getReadEditDashboardDescription: () => + i18n.translate('dashboard.listing.createNewDashboard.combineDataViewFromKibanaAppDescription', { + defaultMessage: + 'Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.', + }), + getSampleDataLinkText: () => + i18n.translate('dashboard.listing.createNewDashboard.sampleDataInstallLinkText', { + defaultMessage: `Add some sample data`, + }), + getCreateNewDashboardText: () => + i18n.translate('dashboard.listing.createNewDashboard.createButtonLabel', { + defaultMessage: `Create a dashboard`, + }), +}; + +export const dashboardUnsavedListingStrings = { + getUnsavedChangesTitle: (plural = false) => + i18n.translate('dashboard.listing.unsaved.unsavedChangesTitle', { + defaultMessage: 'You have unsaved changes in the following {dash}:', + values: { + dash: plural + ? dashboardListingTableStrings.getEntityNamePlural() + : dashboardListingTableStrings.getEntityName(), + }, + }), + getLoadingTitle: () => + i18n.translate('dashboard.listing.unsaved.loading', { + defaultMessage: 'Loading', + }), + getEditAriaLabel: (title: string) => + i18n.translate('dashboard.listing.unsaved.editAria', { + defaultMessage: 'Continue editing {title}', + values: { title }, + }), + getEditTitle: () => + i18n.translate('dashboard.listing.unsaved.editTitle', { + defaultMessage: 'Continue editing', + }), + getDiscardAriaLabel: (title: string) => + i18n.translate('dashboard.listing.unsaved.discardAria', { + defaultMessage: 'Discard changes to {title}', + values: { title }, + }), + getDiscardTitle: () => + i18n.translate('dashboard.listing.unsaved.discardTitle', { + defaultMessage: 'Discard changes', + }), +}; + +/* + Share Modal +*/ +export const shareModalStrings = { + getTopMenuCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.topMenu', { + defaultMessage: 'Top menu', + }), + getQueryCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.query', { + defaultMessage: 'Query', + }), + getTimeFilterCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.timeFilter', { + defaultMessage: 'Time filter', + }), + getFilterBarCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.filterBar', { + defaultMessage: 'Filter bar', + }), + getCheckboxLegend: () => + i18n.translate('dashboard.embedUrlParamExtension.include', { + defaultMessage: 'Include', + }), + getSnapshotShareWarning: () => + i18n.translate('dashboard.snapshotShare.longUrlWarning', { + defaultMessage: + 'One or more panels on this dashboard have changed. Before you generate a snapshot, save the dashboard.', + }), +}; + +/* + Dashboard Top Nav +*/ +export const getDashboardBreadcrumb = () => + i18n.translate('dashboard.dashboardAppBreadcrumbsTitle', { + defaultMessage: 'Dashboard', + }); + +export const topNavStrings = { + fullScreen: { + label: i18n.translate('dashboard.topNave.fullScreenButtonAriaLabel', { + defaultMessage: 'full screen', + }), + + description: i18n.translate('dashboard.topNave.fullScreenConfigDescription', { + defaultMessage: 'Full Screen Mode', + }), + }, + labs: { + label: i18n.translate('dashboard.topNav.labsButtonAriaLabel', { + defaultMessage: 'labs', + }), + description: i18n.translate('dashboard.topNav.labsConfigDescription', { + defaultMessage: 'Labs', + }), + }, + edit: { + label: i18n.translate('dashboard.topNave.editButtonAriaLabel', { + defaultMessage: 'edit', + }), + description: i18n.translate('dashboard.topNave.editConfigDescription', { + defaultMessage: 'Switch to edit mode', + }), + }, + quickSave: { + label: i18n.translate('dashboard.topNave.saveButtonAriaLabel', { + defaultMessage: 'save', + }), + description: i18n.translate('dashboard.topNave.saveConfigDescription', { + defaultMessage: 'Quick save your dashboard without any prompts', + }), + }, + saveAs: { + label: i18n.translate('dashboard.topNave.saveAsButtonAriaLabel', { + defaultMessage: 'save as', + }), + description: i18n.translate('dashboard.topNave.saveAsConfigDescription', { + defaultMessage: 'Save as a new dashboard', + }), + }, + switchToViewMode: { + label: i18n.translate('dashboard.topNave.cancelButtonAriaLabel', { + defaultMessage: 'Switch to view mode', + }), + description: i18n.translate('dashboard.topNave.viewConfigDescription', { + defaultMessage: 'Switch to view-only mode', + }), + }, + share: { + label: i18n.translate('dashboard.topNave.shareButtonAriaLabel', { + defaultMessage: 'share', + }), + description: i18n.translate('dashboard.topNave.shareConfigDescription', { + defaultMessage: 'Share Dashboard', + }), + }, + options: { + label: i18n.translate('dashboard.topNave.optionsButtonAriaLabel', { + defaultMessage: 'options', + }), + description: i18n.translate('dashboard.topNave.optionsConfigDescription', { + defaultMessage: 'Options', + }), + }, + clone: { + label: i18n.translate('dashboard.topNave.cloneButtonAriaLabel', { + defaultMessage: 'clone', + }), + description: i18n.translate('dashboard.topNave.cloneConfigDescription', { + defaultMessage: 'Create a copy of your dashboard', + }), + }, +}; diff --git a/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx new file mode 100644 index 000000000000..a89c35ced6fa --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { History } from 'history'; +import useMount from 'react-use/lib/useMount'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; +import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public'; + +import { + DashboardAppNoDataPage, + isDashboardAppInNoDataState, +} from './no_data/dashboard_app_no_data'; +import { + loadAndRemoveDashboardState, + startSyncingDashboardUrlState, +} from './url/sync_dashboard_url_state'; +import { + getSessionURLObservable, + getSearchSessionIdFromURL, + removeSearchSessionIdFromURL, + createSessionRestorationDataProvider, +} from './url/search_sessions_integration'; +import { DASHBOARD_APP_ID } from '../dashboard_constants'; +import { pluginServices } from '../services/plugin_services'; +import { DashboardTopNav } from './top_nav/dashboard_top_nav'; +import type { DashboardContainer } from '../dashboard_container'; +import { type DashboardEmbedSettings, DashboardRedirect } from './types'; +import { useDashboardMountContext } from './hooks/dashboard_mount_context'; +import { useDashboardOutcomeValidation } from './hooks/use_dashboard_outcome_validation'; +import DashboardContainerRenderer from '../dashboard_container/dashboard_container_renderer'; +import { loadDashboardHistoryLocationState } from './locator/load_dashboard_history_location_state'; +import type { DashboardCreationOptions } from '../dashboard_container/embeddable/dashboard_container_factory'; + +export interface DashboardAppProps { + history: History; + savedDashboardId?: string; + redirectTo: DashboardRedirect; + embedSettings?: DashboardEmbedSettings; +} + +export function DashboardApp({ + savedDashboardId, + embedSettings, + redirectTo, + history, +}: DashboardAppProps) { + const [showNoDataPage, setShowNoDataPage] = useState(false); + useMount(() => { + (async () => setShowNoDataPage(await isDashboardAppInNoDataState()))(); + }); + + const [dashboardContainer, setDashboardContainer] = useState( + undefined + ); + + /** + * Unpack & set up dashboard services + */ + const { + coreContext: { executionContext }, + embeddable: { getStateTransfer }, + notifications: { toasts }, + settings: { uiSettings }, + data: { search }, + } = pluginServices.getServices(); + const incomingEmbeddable = getStateTransfer().getIncomingEmbeddablePackage( + DASHBOARD_APP_ID, + true + ); + const { scopedHistory: getScopedHistory } = useDashboardMountContext(); + + useExecutionContext(executionContext, { + type: 'application', + page: 'app', + id: savedDashboardId || 'new', + }); + + const kbnUrlStateStorage = useMemo( + () => + createKbnUrlStateStorage({ + history, + useHash: uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(toasts), + }), + [toasts, history, uiSettings] + ); + + /** + * Clear search session when leaving dashboard route + */ + useEffect(() => { + return () => { + search.session.clear(); + }; + }, [search.session]); + + /** + * Validate saved object load outcome + */ + const { validateOutcome, getLegacyConflictWarning } = useDashboardOutcomeValidation({ + redirectTo, + }); + + /** + * Create options to pass into the dashboard renderer + */ + const stateFromLocator = loadDashboardHistoryLocationState(getScopedHistory); + const getCreationOptions = useCallback((): DashboardCreationOptions => { + const initialUrlState = loadAndRemoveDashboardState(kbnUrlStateStorage); + const searchSessionIdFromURL = getSearchSessionIdFromURL(history); + return { + incomingEmbeddable, + + // integrations + useControlGroupIntegration: true, + useSessionStorageIntegration: true, + useUnifiedSearchIntegration: true, + unifiedSearchSettings: { + kbnUrlStateStorage, + }, + useSearchSessionsIntegration: true, + searchSessionSettings: { + createSessionRestorationDataProvider, + sessionIdToRestore: searchSessionIdFromURL, + sessionIdUrlChangeObservable: getSessionURLObservable(history), + getSearchSessionIdFromURL: () => getSearchSessionIdFromURL(history), + removeSessionIdFromUrl: () => removeSearchSessionIdFromURL(kbnUrlStateStorage), + }, + + // Override all state with URL + Locator input + overrideInput: { + // State loaded from the dashboard app URL and from the locator overrides all other dashboard state. + ...initialUrlState, + ...stateFromLocator, + }, + + validateLoadedSavedObject: validateOutcome, + }; + }, [kbnUrlStateStorage, history, stateFromLocator, incomingEmbeddable, validateOutcome]); + + /** + * Get the redux wrapper from the dashboard container. This is used to wrap the top nav so it can interact with the + * dashboard's redux state. + */ + const DashboardReduxWrapper = useMemo( + () => dashboardContainer?.getReduxEmbeddableTools().Wrapper, + [dashboardContainer] + ); + + /** + * When the dashboard container is created, or re-created, start syncing dashboard state with the URL + */ + useEffect(() => { + if (!dashboardContainer) return; + const { stopWatchingAppStateInUrl } = startSyncingDashboardUrlState({ + kbnUrlStateStorage, + dashboardContainer, + }); + return () => stopWatchingAppStateInUrl(); + }, [dashboardContainer, kbnUrlStateStorage]); + + return ( + <> + {showNoDataPage && ( + setShowNoDataPage(false)} /> + )} + {!showNoDataPage && ( + <> + {DashboardReduxWrapper && ( + + + + )} + + {getLegacyConflictWarning?.()} + setDashboardContainer(container)} + /> + + )} + + ); +} diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx similarity index 69% rename from src/plugins/dashboard/public/application/dashboard_router.tsx rename to src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx index 9ff31f266468..69dffa5bd616 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx @@ -6,10 +6,10 @@ * Side Public License, v 1. */ -import './index.scss'; +import './_dashboard_app.scss'; + import React from 'react'; import { History } from 'history'; -import { Provider } from 'react-redux'; import { parse, ParsedQuery } from 'query-string'; import { render, unmountComponentAtNode } from 'react-dom'; import { Switch, Route, RouteComponentProps, HashRouter, Redirect } from 'react-router-dom'; @@ -24,18 +24,23 @@ import { I18nProvider, FormattedRelative } from '@kbn/i18n-react'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public'; +import { + createDashboardListingFilterUrl, + CREATE_NEW_DASHBOARD_URL, + DASHBOARD_APP_ID, + LANDING_PAGE_PATH, + VIEW_DASHBOARD_URL, +} from '../dashboard_constants'; import { DashboardListing } from './listing'; -import { dashboardStateStore } from './state'; import { DashboardApp } from './dashboard_app'; -import { addHelpMenuToAppChrome } from './lib'; import { pluginServices } from '../services/plugin_services'; import { DashboardNoMatch } from './listing/dashboard_no_match'; +import { createDashboardEditUrl } from '../dashboard_constants'; import { DashboardStart, DashboardStartDependencies } from '../plugin'; -import { createDashboardListingFilterUrl } from '../dashboard_constants'; +import { DashboardMountContext } from './hooks/dashboard_mount_context'; import { DashboardApplicationService } from '../services/application/types'; -import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants'; -import { dashboardReadonlyBadge, getDashboardPageTitle } from '../dashboard_strings'; -import { DashboardEmbedSettings, RedirectToProps, DashboardMountContextProps } from '../types'; +import { dashboardReadonlyBadge, getDashboardPageTitle } from './_dashboard_app_strings'; +import { DashboardEmbedSettings, DashboardMountContextProps, RedirectToProps } from './types'; export const dashboardUrlParams = { showTopMenu: 'show-top-menu', @@ -58,18 +63,17 @@ type TableListViewApplicationService = DashboardApplicationService & { }; export async function mountApp({ core, element, appUnMounted, mountContext }: DashboardMountProps) { - const { DashboardMountContext } = await import('./hooks/dashboard_mount_context'); - const { application, - chrome: { setBadge, docTitle }, + chrome: { setBadge, docTitle, setHelpExtension }, dashboardCapabilities: { showWriteControls }, + documentationLinks: { dashboardDocLink }, + settings: { uiSettings }, + savedObjectsTagging, data: dataStart, - embeddable, notifications, + embeddable, overlays, - savedObjectsTagging, - settings: { uiSettings }, http, } = pluginServices.getServices(); @@ -80,7 +84,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da createKbnUrlStateStorage({ history, useHash: uiSettings.get('state:storeInSessionStorage'), - ...withNotifyOnErrors(core.notifications.toasts), + ...withNotifyOnErrors(notifications.toasts), }); const redirect = (redirectTo: RedirectToProps) => { @@ -90,7 +94,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da if (redirectTo.destination === 'dashboard') { destination = redirectTo.id ? createDashboardEditUrl(redirectTo.id, redirectTo.editMode) - : DashboardConstants.CREATE_NEW_DASHBOARD_URL; + : CREATE_NEW_DASHBOARD_URL; } else { destination = createDashboardListingFilterUrl(redirectTo.filter); } @@ -149,9 +153,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da }; const hasEmbeddableIncoming = Boolean( - embeddable - .getStateTransfer() - .getIncomingEmbeddablePackage(DashboardConstants.DASHBOARDS_ID, false) + embeddable.getStateTransfer().getIncomingEmbeddablePackage(DASHBOARD_APP_ID, false) ); if (!hasEmbeddableIncoming) { dataStart.dataViews.clearCache(); @@ -165,54 +167,53 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da const app = ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + ); - addHelpMenuToAppChrome(); + setHelpExtension({ + appName: getDashboardPageTitle(), + links: [ + { + linkType: 'documentation', + href: `${dashboardDocLink}`, + }, + ], + }); if (!showWriteControls) { setBadge({ diff --git a/src/plugins/dashboard/public/application/hooks/dashboard_mount_context.ts b/src/plugins/dashboard/public/dashboard_app/hooks/dashboard_mount_context.ts similarity index 93% rename from src/plugins/dashboard/public/application/hooks/dashboard_mount_context.ts rename to src/plugins/dashboard/public/dashboard_app/hooks/dashboard_mount_context.ts index 967fbf67e456..db67405fb113 100644 --- a/src/plugins/dashboard/public/application/hooks/dashboard_mount_context.ts +++ b/src/plugins/dashboard/public/dashboard_app/hooks/dashboard_mount_context.ts @@ -6,9 +6,11 @@ * Side Public License, v 1. */ -import { ScopedHistory } from '@kbn/core-application-browser'; import { createContext, useContext } from 'react'; -import { DashboardMountContextProps } from '../../types'; + +import { ScopedHistory } from '@kbn/core-application-browser'; + +import { DashboardMountContextProps } from '../types'; export const DashboardMountContext = createContext({ // default values for the dashboard mount context diff --git a/src/plugins/dashboard/public/dashboard_app/hooks/use_dashboard_outcome_validation.tsx b/src/plugins/dashboard/public/dashboard_app/hooks/use_dashboard_outcome_validation.tsx new file mode 100644 index 000000000000..847126a9b942 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_app/hooks/use_dashboard_outcome_validation.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 { useCallback, useMemo, useState } from 'react'; + +import { DashboardRedirect } from '../types'; +import { pluginServices } from '../../services/plugin_services'; +import { createDashboardEditUrl } from '../../dashboard_constants'; +import { getDashboardURL404String } from '../_dashboard_app_strings'; +import { useDashboardMountContext } from './dashboard_mount_context'; +import { LoadDashboardFromSavedObjectReturn } from '../../services/dashboard_saved_object/lib/load_dashboard_state_from_saved_object'; + +export const useDashboardOutcomeValidation = ({ + redirectTo, +}: { + redirectTo: DashboardRedirect; +}) => { + const [aliasId, setAliasId] = useState(); + const [outcome, setOutcome] = useState(); + const [savedObjectId, setSavedObjectId] = useState(); + + const { scopedHistory: getScopedHistory } = useDashboardMountContext(); + const scopedHistory = getScopedHistory?.(); + + /** + * Unpack dashboard services + */ + const { + notifications: { toasts }, + screenshotMode, + spaces, + } = pluginServices.getServices(); + + const validateOutcome = useCallback( + ({ dashboardFound, resolveMeta, dashboardId }: LoadDashboardFromSavedObjectReturn) => { + if (!dashboardFound) { + toasts.addDanger(getDashboardURL404String()); + redirectTo({ destination: 'listing' }); + return false; // redirected. Stop loading dashboard. + } + + if (resolveMeta && dashboardId) { + const { + outcome: loadOutcome, + alias_target_id: alias, + alias_purpose: aliasPurpose, + } = resolveMeta; + /** + * Handle saved object resolve alias outcome by redirecting. + */ + if (loadOutcome === 'aliasMatch' && dashboardId && alias) { + const path = scopedHistory.location.hash.replace(dashboardId, alias); + if (screenshotMode.isScreenshotMode()) { + scopedHistory.replace(path); + } else { + spaces.redirectLegacyUrl?.({ path, aliasPurpose }); + return false; // redirected. Stop loading dashboard. + } + } + setAliasId(alias); + setOutcome(loadOutcome); + setSavedObjectId(dashboardId); + } + return true; + }, + [scopedHistory, redirectTo, screenshotMode, spaces, toasts] + ); + + const getLegacyConflictWarning = useMemo(() => { + if (savedObjectId && outcome === 'conflict' && aliasId) { + return () => + spaces.getLegacyUrlConflict?.({ + currentObjectId: savedObjectId, + otherObjectId: aliasId, + otherObjectPath: `#${createDashboardEditUrl(aliasId)}${scopedHistory.location.search}`, + }); + } + return null; + }, [aliasId, outcome, savedObjectId, scopedHistory, spaces]); + + return { validateOutcome, getLegacyConflictWarning }; +}; diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/dashboard_app/listing/__snapshots__/dashboard_listing.test.tsx.snap similarity index 100% rename from src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap rename to src/plugins/dashboard/public/dashboard_app/listing/__snapshots__/dashboard_listing.test.tsx.snap diff --git a/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx b/src/plugins/dashboard/public/dashboard_app/listing/confirm_overlays.tsx similarity index 99% rename from src/plugins/dashboard/public/application/listing/confirm_overlays.tsx rename to src/plugins/dashboard/public/dashboard_app/listing/confirm_overlays.tsx index 58ad770e1f84..e1bd8c339e22 100644 --- a/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx +++ b/src/plugins/dashboard/public/dashboard_app/listing/confirm_overlays.tsx @@ -22,8 +22,8 @@ import { } from '@elastic/eui'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; -import { createConfirmStrings, discardConfirmStrings } from '../../dashboard_strings'; import { pluginServices } from '../../services/plugin_services'; +import { createConfirmStrings, discardConfirmStrings } from '../_dashboard_app_strings'; export type DiscardOrKeepSelection = 'cancel' | 'discard' | 'keep'; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.test.tsx similarity index 100% rename from src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx rename to src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.test.tsx diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.tsx similarity index 95% rename from src/plugins/dashboard/public/application/listing/dashboard_listing.tsx rename to src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.tsx index 4752348246b0..f7de6f0e0845 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.tsx @@ -20,8 +20,8 @@ import { } from '@elastic/eui'; import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; import { syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public'; -import type { SavedObjectsFindOptionsReference, SimpleSavedObject } from '@kbn/core/public'; import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; +import type { SavedObjectsFindOptionsReference, SimpleSavedObject } from '@kbn/core/public'; import { TableListView, type UserContentCommonSchema } from '@kbn/content-management-table-list'; import { @@ -30,17 +30,20 @@ import { noItemsStrings, dashboardUnsavedListingStrings, getNewDashboardTitle, - dashboardSavedObjectErrorStrings, -} from '../../dashboard_strings'; -import { DashboardConstants } from '../..'; -import { DashboardRedirect } from '../../types'; + dashboardListingErrorStrings, +} from '../_dashboard_app_strings'; +import { + DashboardAppNoDataPage, + isDashboardAppInNoDataState, +} from '../no_data/dashboard_app_no_data'; +import { DashboardRedirect } from '../types'; +import { DashboardAttributes } from '../../../common'; import { pluginServices } from '../../services/plugin_services'; import { DashboardUnsavedListing } from './dashboard_unsaved_listing'; +import { DASHBOARD_SAVED_OBJECT_TYPE } from '../../dashboard_constants'; import { getDashboardListItemLink } from './get_dashboard_list_item_link'; import { confirmCreateWithUnsaved, confirmDiscardUnsavedChanges } from './confirm_overlays'; -import { DashboardAppNoDataPage, isDashboardAppInNoDataState } from '../dashboard_app_no_data'; import { DASHBOARD_PANELS_UNSAVED_ID } from '../../services/dashboard_session_storage/dashboard_session_storage_service'; -import { DashboardAttributes } from '../embeddable'; const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit'; const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage'; @@ -293,11 +296,11 @@ export const DashboardListing = ({ await Promise.all( dashboardsToDelete.map(({ id }) => { dashboardSessionStorage.clearState(id); - return savedObjectsClient.delete(DashboardConstants.DASHBOARD_SAVED_OBJECT_TYPE, id); + return savedObjectsClient.delete(DASHBOARD_SAVED_OBJECT_TYPE, id); }) ).catch((error) => { toasts.addError(error, { - title: dashboardSavedObjectErrorStrings.getErrorDeletingDashboardToast(), + title: dashboardListingErrorStrings.getErrorDeletingDashboardToast(), }); }); setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()); diff --git a/src/plugins/dashboard/public/application/listing/dashboard_no_match.tsx b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_no_match.tsx similarity index 95% rename from src/plugins/dashboard/public/application/listing/dashboard_no_match.tsx rename to src/plugins/dashboard/public/dashboard_app/listing/dashboard_no_match.tsx index 03e87f1a344d..895b7ce791fa 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_no_match.tsx +++ b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_no_match.tsx @@ -14,7 +14,7 @@ import { EuiCallOut } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; -import { DashboardConstants } from '../..'; +import { LANDING_PAGE_PATH } from '../../dashboard_constants'; import { pluginServices } from '../../services/plugin_services'; import { useDashboardMountContext } from '../hooks/dashboard_mount_context'; @@ -66,7 +66,7 @@ export const DashboardNoMatch = ({ history }: { history: RouteComponentProps['hi } }, 15000); - history.replace(DashboardConstants.LANDING_PAGE_PATH); + history.replace(LANDING_PAGE_PATH); } }, [restorePreviousUrl, navigateToLegacyKibanaUrl, banners, theme$, history]); diff --git a/src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.test.tsx b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_unsaved_listing.test.tsx similarity index 100% rename from src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.test.tsx rename to src/plugins/dashboard/public/dashboard_app/listing/dashboard_unsaved_listing.test.tsx diff --git a/src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.tsx b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_unsaved_listing.tsx similarity index 97% rename from src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.tsx rename to src/plugins/dashboard/public/dashboard_app/listing/dashboard_unsaved_listing.tsx index 3aa862fe3026..2cf39d8ccb0d 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_unsaved_listing.tsx +++ b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_unsaved_listing.tsx @@ -17,12 +17,12 @@ import { } from '@elastic/eui'; import React, { useCallback, useEffect, useState } from 'react'; -import type { DashboardRedirect } from '../../types'; +import type { DashboardRedirect } from '../types'; +import { DashboardAttributes } from '../../../common'; import { pluginServices } from '../../services/plugin_services'; import { confirmDiscardUnsavedChanges } from './confirm_overlays'; -import { dashboardUnsavedListingStrings, getNewDashboardTitle } from '../../dashboard_strings'; +import { dashboardUnsavedListingStrings, getNewDashboardTitle } from '../_dashboard_app_strings'; import { DASHBOARD_PANELS_UNSAVED_ID } from '../../services/dashboard_session_storage/dashboard_session_storage_service'; -import { DashboardAttributes } from '../embeddable'; const DashboardUnsavedItem = ({ id, diff --git a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts b/src/plugins/dashboard/public/dashboard_app/listing/get_dashboard_list_item_link.test.ts similarity index 100% rename from src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts rename to src/plugins/dashboard/public/dashboard_app/listing/get_dashboard_list_item_link.test.ts diff --git a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts b/src/plugins/dashboard/public/dashboard_app/listing/get_dashboard_list_item_link.ts similarity index 94% rename from src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts rename to src/plugins/dashboard/public/dashboard_app/listing/get_dashboard_list_item_link.ts index 0ee6f016ad6d..521f6de79859 100644 --- a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts +++ b/src/plugins/dashboard/public/dashboard_app/listing/get_dashboard_list_item_link.ts @@ -10,7 +10,7 @@ import type { QueryState } from '@kbn/data-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { - DashboardConstants, + DASHBOARD_APP_ID, createDashboardEditUrl, GLOBAL_STATE_STORAGE_KEY, } from '../../dashboard_constants'; @@ -27,7 +27,7 @@ export const getDashboardListItemLink = ( } = pluginServices.getServices(); const useHash = uiSettings.get('state:storeInSessionStorage'); // use hash - let url = getUrlForApp(DashboardConstants.DASHBOARDS_ID, { + let url = getUrlForApp(DASHBOARD_APP_ID, { path: `#${createDashboardEditUrl(id)}`, }); const globalStateInUrl = kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY) || {}; diff --git a/src/plugins/dashboard/public/application/listing/index.ts b/src/plugins/dashboard/public/dashboard_app/listing/index.ts similarity index 100% rename from src/plugins/dashboard/public/application/listing/index.ts rename to src/plugins/dashboard/public/dashboard_app/listing/index.ts diff --git a/src/plugins/dashboard/public/application/lib/load_dashboard_history_location_state.ts b/src/plugins/dashboard/public/dashboard_app/locator/load_dashboard_history_location_state.ts similarity index 61% rename from src/plugins/dashboard/public/application/lib/load_dashboard_history_location_state.ts rename to src/plugins/dashboard/public/dashboard_app/locator/load_dashboard_history_location_state.ts index 9a7d1791c6c9..c187ab4cdaee 100644 --- a/src/plugins/dashboard/public/application/lib/load_dashboard_history_location_state.ts +++ b/src/plugins/dashboard/public/dashboard_app/locator/load_dashboard_history_location_state.ts @@ -5,14 +5,16 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { ScopedHistory } from '@kbn/core-application-browser'; -import { DashboardState } from '../../types'; -import { ForwardedDashboardState } from '../../locator'; -import { convertSavedPanelsToPanelMap } from '../../../common'; +import { ForwardedDashboardState } from './locator'; +import { convertSavedPanelsToPanelMap, DashboardContainerByValueInput } from '../../../common'; export const loadDashboardHistoryLocationState = ( - state?: ForwardedDashboardState -): Partial => { + getScopedHistory: () => ScopedHistory +): Partial => { + const state = getScopedHistory().location.state as undefined | ForwardedDashboardState; + if (!state) { return {}; } diff --git a/src/plugins/dashboard/public/locator.test.ts b/src/plugins/dashboard/public/dashboard_app/locator/locator.test.ts similarity index 96% rename from src/plugins/dashboard/public/locator.test.ts rename to src/plugins/dashboard/public/dashboard_app/locator/locator.test.ts index 402378162fa1..2b56acc71915 100644 --- a/src/plugins/dashboard/public/locator.test.ts +++ b/src/plugins/dashboard/public/dashboard_app/locator/locator.test.ts @@ -170,24 +170,6 @@ describe('dashboard locator', () => { }); }); - test('savedQuery', async () => { - const definition = new DashboardAppLocatorDefinition({ - useHashedUrl: false, - getDashboardFilterFields: async (dashboardId: string) => [], - }); - const location = await definition.getLocation({ - savedQuery: '__savedQueryId__', - }); - - expect(location).toMatchObject({ - app: 'dashboards', - path: `#/create?_g=()`, - state: { - savedQuery: '__savedQueryId__', - }, - }); - }); - test('panels', async () => { const definition = new DashboardAppLocatorDefinition({ useHashedUrl: false, diff --git a/src/plugins/dashboard/public/locator.ts b/src/plugins/dashboard/public/dashboard_app/locator/locator.ts similarity index 73% rename from src/plugins/dashboard/public/locator.ts rename to src/plugins/dashboard/public/dashboard_app/locator/locator.ts index a66015afcb00..7d03bc1bc65c 100644 --- a/src/plugins/dashboard/public/locator.ts +++ b/src/plugins/dashboard/public/dashboard_app/locator/locator.ts @@ -8,16 +8,15 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { flow } from 'lodash'; -import type { Filter, TimeRange, Query } from '@kbn/es-query'; -import type { GlobalQueryStateFromUrl, RefreshInterval } from '@kbn/data-plugin/public'; -import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; -import { SerializableControlGroupInput } from '@kbn/controls-plugin/common'; + +import type { Filter } from '@kbn/es-query'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { SerializableControlGroupInput } from '@kbn/controls-plugin/common'; +import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; +import type { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; -import type { SavedDashboardPanel } from '../common/types'; -import type { RawDashboardState } from './types'; -import { DashboardConstants } from './dashboard_constants'; +import { DASHBOARD_APP_ID, SEARCH_SESSION_ID } from '../../dashboard_constants'; +import type { DashboardContainerByValueInput, SavedDashboardPanel } from '../../../common'; /** * Useful for ensuring that we don't pass any non-serializable values to history.push (for example, functions). @@ -35,37 +34,18 @@ export const cleanEmptyKeys = (stateObj: Record) => { export const DASHBOARD_APP_LOCATOR = 'DASHBOARD_APP_LOCATOR'; -/** - * We use `type` instead of `interface` to avoid having to extend this type with - * `SerializableRecord`. See https://github.com/microsoft/TypeScript/issues/15300. - */ -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type DashboardAppLocatorParams = { +export type DashboardAppLocatorParams = Partial< + Omit< + DashboardContainerByValueInput, + 'panels' | 'controlGroupInput' | 'executionContext' | 'isEmbeddedExternally' + > +> & { /** * If given, the dashboard saved object with this id will be loaded. If not given, * a new, unsaved dashboard will be loaded up. */ dashboardId?: string; - /** - * Optionally set the time range in the time picker. - */ - timeRange?: TimeRange; - - /** - * Optionally set the refresh interval. - */ - refreshInterval?: RefreshInterval; - /** - * Optionally apply filers. NOTE: if given and used in conjunction with `dashboardId`, and the - * saved dashboard has filters saved with it, this will _replace_ those filters. - */ - filters?: Filter[]; - /** - * Optionally set a query. NOTE: if given and used in conjunction with `dashboardId`, and the - * saved dashboard has a query saved with it, this will _replace_ that query. - */ - query?: Query; /** * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines * whether to hash the data in the url to avoid url length issues. @@ -80,11 +60,6 @@ export type DashboardAppLocatorParams = { */ preserveSavedFilters?: boolean; - /** - * View mode of the dashboard. - */ - viewMode?: ViewMode; - /** * Search search session ID to restore. * (Background search) @@ -96,18 +71,6 @@ export type DashboardAppLocatorParams = { */ panels?: Array; // used SerializableRecord here to force the GridData type to be read as serializable - /** - * Saved query ID - */ - savedQuery?: string; - - /** - * List of tags to set to the state - */ - tags?: string[]; - - options?: RawDashboardState['options']; - /** * Control group input */ @@ -179,11 +142,11 @@ export class DashboardAppLocatorDefinition implements LocatorDefinition name === 'lens'); + const quickButtonVisTypes = ['markdown', 'maps']; + + const trackUiMetric = usageCollection.reportUiCounter?.bind( + usageCollection, + DASHBOARD_UI_METRIC_ID + ); + + const createNewVisType = useCallback( + (visType?: BaseVisType | VisTypeAlias) => () => { + let path = ''; + let appId = ''; + + if (visType) { + if (trackUiMetric) { + trackUiMetric(METRIC_TYPE.CLICK, `${visType.name}:create`); + } + + if ('aliasPath' in visType) { + appId = visType.aliasApp; + path = visType.aliasPath; + } else { + appId = 'visualize'; + path = `#/create?type=${encodeURIComponent(visType.name)}`; + } + } else { + appId = 'visualize'; + path = '#/create?'; + } + + stateTransferService.navigateToEditor(appId, { + path, + state: { + originatingApp: DASHBOARD_APP_ID, + searchSessionId: search.session.getSessionId(), + }, + }); + }, + [stateTransferService, search.session, trackUiMetric] + ); + + const getVisTypeQuickButton = (visTypeName: string) => { + const visType = + getVisualization(visTypeName) || getVisTypeAliases().find(({ name }) => name === visTypeName); + + if (visType) { + if ('aliasPath' in visType) { + const { name, icon, title } = visType as VisTypeAlias; + + return { + iconType: icon, + createType: title, + onClick: createNewVisType(visType as VisTypeAlias), + 'data-test-subj': `dashboardQuickButton${name}`, + }; + } else { + const { name, icon, title, titleInWizard } = visType as BaseVisType; + + return { + iconType: icon, + createType: titleInWizard || title, + onClick: createNewVisType(visType as BaseVisType), + 'data-test-subj': `dashboardQuickButton${name}`, + }; + } + } + return; + }; + + const quickButtons = quickButtonVisTypes + .map(getVisTypeQuickButton) + .filter((button) => button) as QuickButtonProps[]; + + return ( + <> + + + {{ + primaryActionButton: ( + + ), + quickButtonGroup: , + extraButtons: [ + , + dashboardContainer.addFromLibrary()} + data-test-subj="dashboardAddPanelButton" + />, + dashboardContainer.controlGroup?.getToolbarButtons(), + ], + }} + + + ); +} diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx new file mode 100644 index 000000000000..f4e0fa3b44f5 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx @@ -0,0 +1,259 @@ +/* + * 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 UseUnmount from 'react-use/lib/useUnmount'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import type { TopNavMenuProps } from '@kbn/navigation-plugin/public'; +import { withSuspense, LazyLabsFlyout } from '@kbn/presentation-util-plugin/public'; + +import { + getDashboardTitle, + leaveConfirmStrings, + getDashboardBreadcrumb, + unsavedChangesBadgeStrings, +} from '../_dashboard_app_strings'; +import { UI_SETTINGS } from '../../../common'; +import { pluginServices } from '../../services/plugin_services'; +import { useDashboardMenuItems } from './use_dashboard_menu_items'; +import { DashboardEmbedSettings, DashboardRedirect } from '../types'; +import { DashboardEditingToolbar } from './dashboard_editing_toolbar'; +import { useDashboardMountContext } from '../hooks/dashboard_mount_context'; +import { getFullEditPath, LEGACY_DASHBOARD_APP_ID } from '../../dashboard_constants'; +import { useDashboardContainerContext } from '../../dashboard_container/dashboard_container_renderer'; + +export interface DashboardTopNavProps { + embedSettings?: DashboardEmbedSettings; + redirectTo: DashboardRedirect; +} + +const LabsFlyout = withSuspense(LazyLabsFlyout, null); + +export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavProps) { + const [isChromeVisible, setIsChromeVisible] = useState(false); + const [isLabsShown, setIsLabsShown] = useState(false); + + const dashboardTitleRef = useRef(null); + + /** + * Unpack dashboard services + */ + const { + data: { + query: { filterManager }, + }, + chrome: { + setBreadcrumbs, + setIsVisible: setChromeVisibility, + getIsVisible$: getChromeIsVisible$, + recentlyAccessed: chromeRecentlyAccessed, + }, + settings: { uiSettings }, + navigation: { TopNavMenu }, + embeddable: { getStateTransfer }, + initializerContext: { allowByValueEmbeddables }, + dashboardCapabilities: { saveQuery: showSaveQuery }, + } = pluginServices.getServices(); + const isLabsEnabled = uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI); + const { setHeaderActionMenu, onAppLeave } = useDashboardMountContext(); + + /** + * Unpack dashboard state from redux + */ + const { + useEmbeddableDispatch, + actions: { setSavedQueryId }, + useEmbeddableSelector: select, + embeddableInstance: dashboardContainer, + } = useDashboardContainerContext(); + const dispatch = useEmbeddableDispatch(); + + const hasUnsavedChanges = select((state) => state.componentState.hasUnsavedChanges); + const fullScreenMode = select((state) => state.componentState.fullScreenMode); + const savedQueryId = select((state) => state.componentState.savedQueryId); + const lastSavedId = select((state) => state.componentState.lastSavedId); + const viewMode = select((state) => state.explicitInput.viewMode); + const query = select((state) => state.explicitInput.query); + const title = select((state) => state.explicitInput.title); + + // store data views in state & subscribe to dashboard data view changes. + const [allDataViews, setAllDataViews] = useState( + dashboardContainer.getAllDataViews() + ); + useEffect(() => { + const subscription = dashboardContainer.onDataViewsUpdate$.subscribe((dataViews) => + setAllDataViews(dataViews) + ); + return () => subscription.unsubscribe(); + }, [dashboardContainer]); + + const dashboardTitle = useMemo(() => { + return getDashboardTitle(title, viewMode, !lastSavedId); + }, [title, viewMode, lastSavedId]); + + /** + * focus on the top header when title or view mode is changed + */ + useEffect(() => { + dashboardTitleRef.current?.focus(); + }, [title, viewMode]); + + /** + * Manage chrome visibility when dashboard is embedded. + */ + useEffect(() => { + if (!embedSettings) setChromeVisibility(viewMode !== ViewMode.PRINT); + }, [embedSettings, setChromeVisibility, viewMode]); + + /** + * populate recently accessed, and set is chrome visible. + */ + useEffect(() => { + const subscription = getChromeIsVisible$().subscribe((visible) => setIsChromeVisible(visible)); + if (lastSavedId && title) { + chromeRecentlyAccessed.add( + getFullEditPath(lastSavedId, viewMode === ViewMode.EDIT), + title, + lastSavedId + ); + } + return () => subscription.unsubscribe(); + }, [ + allowByValueEmbeddables, + chromeRecentlyAccessed, + getChromeIsVisible$, + lastSavedId, + viewMode, + title, + ]); + + /** + * Set breadcrumbs to dashboard title when dashboard's title or view mode changes + */ + useEffect(() => { + setBreadcrumbs([ + { + text: getDashboardBreadcrumb(), + 'data-test-subj': 'dashboardListingBreadcrumb', + onClick: () => { + redirectTo({ destination: 'listing' }); + }, + }, + { + text: dashboardTitle, + }, + ]); + }, [setBreadcrumbs, redirectTo, dashboardTitle]); + + /** + * Build app leave handler whenever hasUnsavedChanges changes + */ + useEffect(() => { + onAppLeave((actions) => { + if ( + viewMode === ViewMode.EDIT && + hasUnsavedChanges && + !getStateTransfer().isTransferInProgress + ) { + return actions.confirm( + leaveConfirmStrings.getLeaveSubtitle(), + leaveConfirmStrings.getLeaveTitle() + ); + } + return actions.default(); + }); + return () => { + // reset on app leave handler so leaving from the listing page doesn't trigger a confirmation + onAppLeave((actions) => actions.default()); + }; + }, [onAppLeave, getStateTransfer, hasUnsavedChanges, viewMode]); + + const { viewModeTopNavConfig, editModeTopNavConfig } = useDashboardMenuItems({ + redirectTo, + isLabsShown, + setIsLabsShown, + }); + + const getNavBarProps = (): TopNavMenuProps => { + const shouldShowNavBarComponent = (forceShow: boolean): boolean => + (forceShow || isChromeVisible) && !fullScreenMode; + + const shouldShowFilterBar = (forceHide: boolean): boolean => + !forceHide && (filterManager.getFilters().length > 0 || !fullScreenMode); + + const showTopNavMenu = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowTopNavMenu)); + const showQueryInput = shouldShowNavBarComponent( + Boolean(embedSettings?.forceShowQueryInput || viewMode === ViewMode.PRINT) + ); + const showDatePicker = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker)); + const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); + const showQueryBar = showQueryInput || showDatePicker || showFilterBar; + const showSearchBar = showQueryBar || showFilterBar; + const topNavConfig = viewMode === ViewMode.EDIT ? editModeTopNavConfig : viewModeTopNavConfig; + + const badges = + hasUnsavedChanges && viewMode === ViewMode.EDIT + ? [ + { + 'data-test-subj': 'dashboardUnsavedChangesBadge', + badgeText: unsavedChangesBadgeStrings.getUnsavedChangedBadgeText(), + color: 'success', + }, + ] + : undefined; + + return { + query, + badges, + savedQueryId, + showSearchBar, + showFilterBar, + showSaveQuery, + showQueryInput, + showDatePicker, + screenTitle: title, + useDefaultBehaviors: true, + appName: LEGACY_DASHBOARD_APP_ID, + visible: viewMode !== ViewMode.PRINT, + indexPatterns: allDataViews, + config: showTopNavMenu ? topNavConfig : undefined, + setMenuMountPoint: embedSettings ? undefined : setHeaderActionMenu, + className: fullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined, + onQuerySubmit: (_payload, isUpdate) => { + if (isUpdate === false) { + dashboardContainer.forceRefresh(); + } + }, + onSavedQueryIdChange: (newId: string | undefined) => { + dispatch(setSavedQueryId(newId)); + }, + }; + }; + + UseUnmount(() => { + dashboardContainer.clearOverlays(); + }); + + return ( + <> +

{`${getDashboardBreadcrumb()} - ${dashboardTitle}`}

+ + {viewMode !== ViewMode.PRINT && isLabsEnabled && isLabsShown ? ( + setIsLabsShown(false)} /> + ) : null} + {viewMode === ViewMode.EDIT ? : null} + + ); +} diff --git a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.tsx similarity index 94% rename from src/plugins/dashboard/public/application/top_nav/editor_menu.tsx rename to src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.tsx index 1f17e0b6ef6a..ae338145915c 100644 --- a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.tsx @@ -25,14 +25,12 @@ import type { EmbeddableInput, } from '@kbn/embeddable-plugin/public'; -import { DashboardContainer } from '..'; -import { DashboardConstants } from '../../dashboard_constants'; -import { dashboardReplacePanelAction } from '../../dashboard_strings'; import { pluginServices } from '../../services/plugin_services'; +import { getPanelAddedSuccessString } from '../_dashboard_app_strings'; +import { DASHBOARD_APP_ID, DASHBOARD_UI_METRIC_ID } from '../../dashboard_constants'; +import { useDashboardContainerContext } from '../../dashboard_container/dashboard_container_renderer'; interface Props { - /** Dashboard container */ - dashboardContainer: DashboardContainer; /** Handler for creating new visualization of a specified type */ createNewVisType: (visType: BaseVisType | VisTypeAlias) => () => void; } @@ -50,7 +48,7 @@ interface UnwrappedEmbeddableFactory { isEditable: boolean; } -export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { +export const EditorMenu = ({ createNewVisType }: Props) => { const { embeddable, notifications: { toasts }, @@ -63,6 +61,8 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { }, } = pluginServices.getServices(); + const { embeddableInstance: dashboardContainer } = useDashboardContainerContext(); + const embeddableFactories = useMemo( () => Array.from(embeddable.getEmbeddableFactories()), [embeddable] @@ -86,13 +86,13 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { const trackUiMetric = usageCollection.reportUiCounter?.bind( usageCollection, - DashboardConstants.DASHBOARD_ID + DASHBOARD_UI_METRIC_ID ); const createNewAggsBasedVis = useCallback( (visType?: BaseVisType) => () => showNewVisModal({ - originatingApp: DashboardConstants.DASHBOARDS_ID, + originatingApp: DASHBOARD_APP_ID, outsideVisualizeApp: true, showAggsSelection: true, selectedVisType: visType, @@ -237,9 +237,7 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { if (newEmbeddable) { toasts.addSuccess({ - title: dashboardReplacePanelAction.getSuccessMessage( - `'${newEmbeddable.getInput().title}'` || '' - ), + title: getPanelAddedSuccessString(`'${newEmbeddable.getInput().title}'` || ''), 'data-test-subj': 'addEmbeddableToDashboardSuccess', }); } diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx similarity index 81% rename from src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx rename to src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx index 5f6dc325ce97..f2603110ebaa 100644 --- a/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx @@ -7,11 +7,10 @@ */ import { Capabilities } from '@kbn/core/public'; +import { convertPanelMapToSavedPanels, DashboardContainerByValueInput } from '../../../../common'; -import { DashboardState } from '../../types'; -import { DashboardAppLocatorParams } from '../..'; -import { pluginServices } from '../../services/plugin_services'; -import { stateToRawDashboardState } from '../lib/convert_dashboard_state'; +import { DashboardAppLocatorParams } from '../../..'; +import { pluginServices } from '../../../services/plugin_services'; import { showPublicUrlSwitch, ShowShareModal, ShowShareModalProps } from './show_share_modal'; describe('showPublicUrlSwitch', () => { @@ -68,14 +67,15 @@ describe('ShowShareModal', () => { jest.clearAllMocks(); }); - const getPropsAndShare = (unsavedState?: Partial): ShowShareModalProps => { + const getPropsAndShare = ( + unsavedState?: Partial + ): ShowShareModalProps => { pluginServices.getServices().dashboardSessionStorage.getState = jest .fn() .mockReturnValue(unsavedState); return { isDirty: true, anchorElement: document.createElement('div'), - currentDashboardState: { panels: {} } as DashboardState, }; }; @@ -94,7 +94,7 @@ describe('ShowShareModal', () => { }); it('locatorParams unsaved state is properly propagated to locator', () => { - const unsavedDashboardState: DashboardState = { + const unsavedDashboardState: DashboardContainerByValueInput = { panels: { panel_1: { type: 'panel_type', @@ -105,13 +105,11 @@ describe('ShowShareModal', () => { }, }, }, - options: { - hidePanelTitles: true, - useMargins: true, - syncColors: true, - syncCursor: true, - syncTooltips: true, - }, + hidePanelTitles: true, + useMargins: true, + syncColors: true, + syncCursor: true, + syncTooltips: true, filters: [ { meta: { @@ -123,8 +121,7 @@ describe('ShowShareModal', () => { }, ], query: { query: 'bye', language: 'kuery' }, - savedQuery: 'amazingSavedQuery', - } as unknown as DashboardState; + } as unknown as DashboardContainerByValueInput; const showModalProps = getPropsAndShare(unsavedDashboardState); ShowShareModal(showModalProps); expect(toggleShareMenuSpy).toHaveBeenCalledTimes(1); @@ -133,9 +130,13 @@ describe('ShowShareModal', () => { locatorParams: { params: DashboardAppLocatorParams }; } ).locatorParams.params; - const rawDashboardState = stateToRawDashboardState({ - state: unsavedDashboardState, - }); + const { + initializerContext: { kibanaVersion }, + } = pluginServices.getServices(); + const rawDashboardState = { + ...unsavedDashboardState, + panels: convertPanelMapToSavedPanels(unsavedDashboardState.panels, kibanaVersion), + }; unsavedStateKeys.forEach((key) => { expect(shareLocatorParams[key]).toStrictEqual( (rawDashboardState as unknown as Partial)[key] diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx similarity index 86% rename from src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx rename to src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx index 6cff8ff20a9d..cc25ac920899 100644 --- a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx @@ -19,20 +19,19 @@ import { getStateFromKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { setStateToKbnUrl, unhashUrl } from '@kbn/kibana-utils-plugin/public'; import type { SerializableControlGroupInput } from '@kbn/controls-plugin/common'; -import type { DashboardState } from '../../types'; -import { dashboardUrlParams } from '../dashboard_router'; -import { shareModalStrings } from '../../dashboard_strings'; -import { convertPanelMapToSavedPanels } from '../../../common'; -import { pluginServices } from '../../services/plugin_services'; -import { DashboardAppLocatorParams, DASHBOARD_APP_LOCATOR } from '../../locator'; -import { stateToRawDashboardState } from '../lib/convert_dashboard_state'; +import { dashboardUrlParams } from '../../dashboard_router'; +import { shareModalStrings } from '../../_dashboard_app_strings'; +import { pluginServices } from '../../../services/plugin_services'; +import { convertPanelMapToSavedPanels } from '../../../../common'; +import { DashboardAppLocatorParams, DASHBOARD_APP_LOCATOR } from '../../locator/locator'; const showFilterBarId = 'showFilterBar'; export interface ShowShareModalProps { isDirty: boolean; + savedObjectId?: string; + dashboardTitle?: string; anchorElement: HTMLElement; - currentDashboardState: DashboardState; } export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { @@ -46,7 +45,8 @@ export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => export function ShowShareModal({ isDirty, anchorElement, - currentDashboardState, + savedObjectId, + dashboardTitle, }: ShowShareModalProps) { const { dashboardCapabilities: { createShortUrl: allowShortUrl }, @@ -121,19 +121,13 @@ export function ShowShareModal({ ); }; - let unsavedStateForLocator: Pick< - DashboardAppLocatorParams, - 'options' | 'query' | 'savedQuery' | 'filters' | 'panels' | 'controlGroupInput' - > = {}; - const { savedObjectId, title } = currentDashboardState; + let unsavedStateForLocator: DashboardAppLocatorParams = {}; const unsavedDashboardState = dashboardSessionStorage.getState(savedObjectId); if (unsavedDashboardState) { unsavedStateForLocator = { query: unsavedDashboardState.query, filters: unsavedDashboardState.filters, - options: unsavedDashboardState.options, - savedQuery: unsavedDashboardState.savedQuery, controlGroupInput: unsavedDashboardState.controlGroupInput as SerializableControlGroupInput, panels: unsavedDashboardState.panels ? (convertPanelMapToSavedPanels( @@ -141,6 +135,13 @@ export function ShowShareModal({ kibanaVersion ) as DashboardAppLocatorParams['panels']) : undefined, + + // options + useMargins: unsavedDashboardState?.useMargins, + syncColors: unsavedDashboardState?.syncColors, + syncCursor: unsavedDashboardState?.syncCursor, + syncTooltips: unsavedDashboardState?.syncTooltips, + hidePanelTitles: unsavedDashboardState?.hidePanelTitles, }; } @@ -162,7 +163,7 @@ export function ShowShareModal({ const shareableUrl = setStateToKbnUrl( '_a', - stateToRawDashboardState({ state: unsavedDashboardState ?? {} }), + unsavedStateForLocator, { useHash: false, storeInHashQuery: true }, unhashUrl(baseUrl) ); @@ -177,7 +178,7 @@ export function ShowShareModal({ objectType: 'dashboard', sharingData: { title: - title || + dashboardTitle || i18n.translate('dashboard.share.defaultDashboardTitle', { defaultMessage: 'Dashboard [{date}]', values: { date: moment().toISOString(true) }, diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx new file mode 100644 index 000000000000..b107a82bdaa3 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx @@ -0,0 +1,256 @@ +/* + * 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 { batch } from 'react-redux'; +import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'; + +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { TopNavMenuData } from '@kbn/navigation-plugin/public'; + +import { DashboardRedirect } from '../types'; +import { UI_SETTINGS } from '../../../common'; +import { topNavStrings } from '../_dashboard_app_strings'; +import { ShowShareModal } from './share/show_share_modal'; +import { pluginServices } from '../../services/plugin_services'; +import { CHANGE_CHECK_DEBOUNCE } from '../../dashboard_constants'; +import { SaveDashboardReturn } from '../../services/dashboard_saved_object/types'; +import { useDashboardContainerContext } from '../../dashboard_container/dashboard_container_renderer'; +import { confirmDiscardUnsavedChanges } from '../listing/confirm_overlays'; + +export const useDashboardMenuItems = ({ + redirectTo, + isLabsShown, + setIsLabsShown, +}: { + redirectTo: DashboardRedirect; + isLabsShown: boolean; + setIsLabsShown: Dispatch>; +}) => { + const [isSaveInProgress, setIsSaveInProgress] = useState(false); + + /** + * Unpack dashboard services + */ + const { + share, + settings: { uiSettings }, + dashboardCapabilities: { showWriteControls }, + } = pluginServices.getServices(); + const isLabsEnabled = uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI); + + /** + * Unpack dashboard state from redux + */ + const { + useEmbeddableDispatch, + useEmbeddableSelector: select, + embeddableInstance: dashboardContainer, + actions: { setViewMode, setFullScreenMode }, + } = useDashboardContainerContext(); + const dispatch = useEmbeddableDispatch(); + + const hasUnsavedChanges = select((state) => state.componentState.hasUnsavedChanges); + const lastSavedId = select((state) => state.componentState.lastSavedId); + const dashboardTitle = select((state) => state.explicitInput.title); + + /** + * Show the Dashboard app's share menu + */ + const showShare = useCallback( + (anchorElement: HTMLElement) => { + ShowShareModal({ + dashboardTitle, + anchorElement, + savedObjectId: lastSavedId, + isDirty: Boolean(hasUnsavedChanges), + }); + }, + [dashboardTitle, hasUnsavedChanges, lastSavedId] + ); + + const maybeRedirect = useCallback( + (result?: SaveDashboardReturn) => { + if (!result) return; + const { redirectRequired, id } = result; + if (redirectRequired) { + redirectTo({ + id, + editMode: true, + useReplace: true, + destination: 'dashboard', + }); + } + }, + [redirectTo] + ); + + /** + * Save the dashboard without any UI or popups. + */ + const quickSaveDashboard = useCallback(() => { + setIsSaveInProgress(true); + dashboardContainer + .runQuickSave() + .then(() => setTimeout(() => setIsSaveInProgress(false), CHANGE_CHECK_DEBOUNCE)); + }, [dashboardContainer]); + + /** + * Show the dashboard's save modal + */ + const saveDashboardAs = useCallback(() => { + dashboardContainer.runSaveAs().then((result) => maybeRedirect(result)); + }, [maybeRedirect, dashboardContainer]); + + /** + * Clone the dashboard + */ + const clone = useCallback(() => { + dashboardContainer.runClone().then((result) => maybeRedirect(result)); + }, [maybeRedirect, dashboardContainer]); + + /** + * Returns to view mode. If the dashboard has unsaved changes shows a warning and resets to last saved state. + */ + const returnToViewMode = useCallback(() => { + dashboardContainer.clearOverlays(); + if (hasUnsavedChanges) { + confirmDiscardUnsavedChanges(() => { + batch(() => { + dashboardContainer.resetToLastSavedState(); + dispatch(setViewMode(ViewMode.VIEW)); + }); + }); + return; + } + dispatch(setViewMode(ViewMode.VIEW)); + }, [dashboardContainer, dispatch, hasUnsavedChanges, setViewMode]); + + /** + * Register all of the top nav configs that can be used by dashboard. + */ + const menuItems = useMemo(() => { + return { + fullScreen: { + ...topNavStrings.fullScreen, + id: 'full-screen', + testId: 'dashboardFullScreenMode', + run: () => dispatch(setFullScreenMode(true)), + } as TopNavMenuData, + + labs: { + ...topNavStrings.labs, + id: 'labs', + testId: 'dashboardLabs', + run: () => setIsLabsShown(!isLabsShown), + } as TopNavMenuData, + + edit: { + ...topNavStrings.edit, + emphasize: true, + id: 'edit', + iconType: 'pencil', + testId: 'dashboardEditMode', + className: 'eui-hideFor--s eui-hideFor--xs', // hide for small screens - editing doesn't work in mobile mode. + run: () => dispatch(setViewMode(ViewMode.EDIT)), + } as TopNavMenuData, + + quickSave: { + ...topNavStrings.quickSave, + id: 'quick-save', + iconType: 'save', + emphasize: true, + isLoading: isSaveInProgress, + testId: 'dashboardQuickSaveMenuItem', + disableButton: !hasUnsavedChanges || isSaveInProgress, + run: () => quickSaveDashboard(), + } as TopNavMenuData, + + saveAs: { + description: topNavStrings.saveAs.description, + disableButton: isSaveInProgress, + id: 'save', + emphasize: !Boolean(lastSavedId), + testId: 'dashboardSaveMenuItem', + iconType: Boolean(lastSavedId) ? undefined : 'save', + label: Boolean(lastSavedId) ? topNavStrings.saveAs.label : topNavStrings.quickSave.label, + run: () => saveDashboardAs(), + } as TopNavMenuData, + + switchToViewMode: { + ...topNavStrings.switchToViewMode, + id: 'cancel', + disableButton: isSaveInProgress || !lastSavedId, + testId: 'dashboardViewOnlyMode', + run: () => returnToViewMode(), + } as TopNavMenuData, + + share: { + ...topNavStrings.share, + id: 'share', + testId: 'shareTopNavButton', + disableButton: isSaveInProgress, + run: showShare, + } as TopNavMenuData, + + options: { + ...topNavStrings.options, + id: 'options', + testId: 'dashboardOptionsButton', + disableButton: isSaveInProgress, + run: (anchor) => dashboardContainer.showOptions(anchor), + } as TopNavMenuData, + + clone: { + ...topNavStrings.clone, + id: 'clone', + testId: 'dashboardClone', + disableButton: isSaveInProgress, + run: () => clone(), + } as TopNavMenuData, + }; + }, [ + quickSaveDashboard, + dashboardContainer, + hasUnsavedChanges, + setFullScreenMode, + isSaveInProgress, + returnToViewMode, + saveDashboardAs, + setIsLabsShown, + lastSavedId, + setViewMode, + isLabsShown, + showShare, + dispatch, + clone, + ]); + + /** + * Build ordered menus for view and edit mode. + */ + const viewModeTopNavConfig = useMemo(() => { + const labsMenuItem = isLabsEnabled ? [menuItems.labs] : []; + const shareMenuItem = share ? [menuItems.share] : []; + const writePermissionsMenuItems = showWriteControls ? [menuItems.clone, menuItems.edit] : []; + return [...labsMenuItem, menuItems.fullScreen, ...shareMenuItem, ...writePermissionsMenuItems]; + }, [menuItems, share, showWriteControls, isLabsEnabled]); + + const editModeTopNavConfig = useMemo(() => { + const labsMenuItem = isLabsEnabled ? [menuItems.labs] : []; + const shareMenuItem = share ? [menuItems.share] : []; + const editModeItems: TopNavMenuData[] = []; + if (lastSavedId) { + editModeItems.push(menuItems.saveAs, menuItems.switchToViewMode, menuItems.quickSave); + } else { + editModeItems.push(menuItems.switchToViewMode, menuItems.saveAs); + } + return [...labsMenuItem, menuItems.options, ...shareMenuItem, ...editModeItems]; + }, [lastSavedId, menuItems, share, isLabsEnabled]); + + return { viewModeTopNavConfig, editModeTopNavConfig }; +}; diff --git a/src/plugins/dashboard/public/dashboard_app/types.ts b/src/plugins/dashboard/public/dashboard_app/types.ts new file mode 100644 index 000000000000..cc33cec973ee --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_app/types.ts @@ -0,0 +1,28 @@ +/* + * 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 { AppMountParameters, ScopedHistory } from '@kbn/core-application-browser'; + +export type DashboardRedirect = (props: RedirectToProps) => void; +export type RedirectToProps = + | { destination: 'dashboard'; id?: string; useReplace?: boolean; editMode?: boolean } + | { destination: 'listing'; filter?: string; useReplace?: boolean }; + +export interface DashboardEmbedSettings { + forceHideFilterBar?: boolean; + forceShowTopNavMenu?: boolean; + forceShowQueryInput?: boolean; + forceShowDatePicker?: boolean; +} + +export interface DashboardMountContextProps { + restorePreviousUrl: () => void; + scopedHistory: () => ScopedHistory; + onAppLeave: AppMountParameters['onAppLeave']; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; +} diff --git a/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts b/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts new file mode 100644 index 000000000000..752ee39724de --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts @@ -0,0 +1,110 @@ +/* + * 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 { map } from 'rxjs'; +import { History } from 'history'; + +import { + getQueryParams, + replaceUrlHashQuery, + IKbnUrlStateStorage, + createQueryParamObservable, +} from '@kbn/kibana-utils-plugin/public'; +import type { Query } from '@kbn/es-query'; +import { SearchSessionInfoProvider } from '@kbn/data-plugin/public'; + +import { SEARCH_SESSION_ID } from '../../dashboard_constants'; +import { DashboardContainer } from '../../dashboard_container'; +import { convertPanelMapToSavedPanels } from '../../../common'; +import { pluginServices } from '../../services/plugin_services'; +import { DashboardAppLocatorParams, DASHBOARD_APP_LOCATOR } from '../locator/locator'; + +export const removeSearchSessionIdFromURL = (kbnUrlStateStorage: IKbnUrlStateStorage) => { + kbnUrlStateStorage.kbnUrlControls.updateAsync((nextUrl) => { + if (nextUrl.includes(SEARCH_SESSION_ID)) { + return replaceUrlHashQuery(nextUrl, (hashQuery) => { + delete hashQuery[SEARCH_SESSION_ID]; + return hashQuery; + }); + } + return nextUrl; + }); +}; + +export const getSearchSessionIdFromURL = (history: History): string | undefined => + getQueryParams(history.location)[SEARCH_SESSION_ID] as string | undefined; + +export const getSessionURLObservable = (history: History) => + createQueryParamObservable(history, SEARCH_SESSION_ID).pipe( + map((sessionId) => sessionId ?? undefined) + ); + +export function createSessionRestorationDataProvider( + container: DashboardContainer +): SearchSessionInfoProvider { + return { + getName: async () => container.getTitle(), + getLocatorData: async () => ({ + id: DASHBOARD_APP_LOCATOR, + initialState: getLocatorParams({ container, shouldRestoreSearchSession: false }), + restoreState: getLocatorParams({ container, shouldRestoreSearchSession: true }), + }), + }; +} + +/** + * Fetches the state to store when a session is saved so that this dashboard can be recreated exactly + * as it was. + */ +function getLocatorParams({ + container, + shouldRestoreSearchSession, +}: { + container: DashboardContainer; + shouldRestoreSearchSession: boolean; +}): DashboardAppLocatorParams { + const { + data: { + query: { + queryString, + filterManager, + timefilter: { timefilter }, + }, + search: { session }, + }, + initializerContext: { kibanaVersion }, + } = pluginServices.getServices(); + + const { + componentState: { lastSavedId }, + explicitInput: { panels, query, viewMode }, + } = container.getReduxEmbeddableTools().getState(); + + return { + viewMode, + useHash: false, + preserveSavedFilters: false, + filters: filterManager.getFilters(), + query: queryString.formatQuery(query) as Query, + dashboardId: container.getDashboardSavedObjectId(), + searchSessionId: shouldRestoreSearchSession ? session.getSessionId() : undefined, + timeRange: shouldRestoreSearchSession ? timefilter.getAbsoluteTime() : timefilter.getTime(), + refreshInterval: shouldRestoreSearchSession + ? { + pause: true, // force pause refresh interval when restoring a session + value: 0, + } + : undefined, + panels: lastSavedId + ? undefined + : (convertPanelMapToSavedPanels( + panels, + kibanaVersion + ) as DashboardAppLocatorParams['panels']), + }; +} diff --git a/src/plugins/dashboard/public/dashboard_app/url/sync_dashboard_url_state.ts b/src/plugins/dashboard/public/dashboard_app/url/sync_dashboard_url_state.ts new file mode 100644 index 000000000000..b0d37de482de --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_app/url/sync_dashboard_url_state.ts @@ -0,0 +1,94 @@ +/* + * 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 _ from 'lodash'; +import { debounceTime } from 'rxjs/operators'; +import semverSatisfies from 'semver/functions/satisfies'; + +import { IKbnUrlStateStorage, replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/public'; + +import { + DashboardPanelMap, + SavedDashboardPanel, + SharedDashboardState, + convertSavedPanelsToPanelMap, + DashboardContainerByValueInput, +} from '../../../common'; +import { DashboardContainer } from '../../dashboard_container'; +import { pluginServices } from '../../services/plugin_services'; +import { getPanelTooOldErrorString } from '../_dashboard_app_strings'; +import { DASHBOARD_STATE_STORAGE_KEY } from '../../dashboard_constants'; +import { migrateLegacyQuery } from '../../services/dashboard_saved_object/lib/load_dashboard_state_from_saved_object'; + +/** + * We no longer support loading panels from a version older than 7.3 in the URL. + * @returns whether or not there is a panel in the URL state saved with a version before 7.3 + */ +export const isPanelVersionTooOld = (panels: SavedDashboardPanel[]) => { + for (const panel of panels) { + if (!panel.version || semverSatisfies(panel.version, '<7.3')) return true; + } + return false; +}; + +/** + * Loads any dashboard state from the URL, and removes the state from the URL. + */ +export const loadAndRemoveDashboardState = ( + kbnUrlStateStorage: IKbnUrlStateStorage +): Partial => { + const { + notifications: { toasts }, + } = pluginServices.getServices(); + const rawAppStateInUrl = kbnUrlStateStorage.get( + DASHBOARD_STATE_STORAGE_KEY + ); + if (!rawAppStateInUrl) return {}; + + let panelsMap: DashboardPanelMap | undefined; + if (rawAppStateInUrl.panels && rawAppStateInUrl.panels.length > 0) { + if (isPanelVersionTooOld(rawAppStateInUrl.panels)) { + toasts.addWarning(getPanelTooOldErrorString()); + } else { + panelsMap = convertSavedPanelsToPanelMap(rawAppStateInUrl.panels); + } + } + + const nextUrl = replaceUrlHashQuery(window.location.href, (hashQuery) => { + delete hashQuery[DASHBOARD_STATE_STORAGE_KEY]; + return hashQuery; + }); + kbnUrlStateStorage.kbnUrlControls.update(nextUrl, true); + const partialState: Partial = { + ..._.omit(rawAppStateInUrl, ['panels', 'query']), + ...(panelsMap ? { panels: panelsMap } : {}), + ...(rawAppStateInUrl.query ? { query: migrateLegacyQuery(rawAppStateInUrl.query) } : {}), + }; + + return partialState; +}; + +export const startSyncingDashboardUrlState = ({ + kbnUrlStateStorage, + dashboardContainer, +}: { + kbnUrlStateStorage: IKbnUrlStateStorage; + dashboardContainer: DashboardContainer; +}) => { + const appStateSubscription = kbnUrlStateStorage + .change$(DASHBOARD_STATE_STORAGE_KEY) + .pipe(debounceTime(10)) // debounce URL updates so react has time to unsubscribe when changing URLs + .subscribe(() => { + const stateFromUrl = loadAndRemoveDashboardState(kbnUrlStateStorage); + if (Object.keys(stateFromUrl).length === 0) return; + dashboardContainer.updateInput(stateFromUrl); + }); + + const stopWatchingAppStateInUrl = () => appStateSubscription.unsubscribe(); + return { stopWatchingAppStateInUrl }; +}; diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index c4856f8e2e34..fc2d609cae6e 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -7,50 +7,17 @@ */ import { ViewMode } from '@kbn/embeddable-plugin/common'; -import type { DashboardState } from './types'; +import type { DashboardContainerByValueInput } from '../common'; +// ------------------------------------------------------------------ +// URL Constants +// ------------------------------------------------------------------ export const DASHBOARD_STATE_STORAGE_KEY = '_a'; export const GLOBAL_STATE_STORAGE_KEY = '_g'; - -export const DASHBOARD_GRID_COLUMN_COUNT = 48; -export const DASHBOARD_GRID_HEIGHT = 20; -export const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2; -export const DEFAULT_PANEL_HEIGHT = 15; -export const DASHBOARD_CONTAINER_TYPE = 'dashboard'; - -export const DashboardConstants = { - LANDING_PAGE_PATH: '/list', - CREATE_NEW_DASHBOARD_URL: '/create', - VIEW_DASHBOARD_URL: '/view', - PRINT_DASHBOARD_URL: '/print', - ADD_EMBEDDABLE_ID: 'addEmbeddableId', - ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', - DASHBOARDS_ID: 'dashboards', - DASHBOARD_ID: 'dashboard', - DASHBOARD_SAVED_OBJECT_TYPE: 'dashboard', - SEARCH_SESSION_ID: 'searchSessionId', - CHANGE_CHECK_DEBOUNCE: 100, - CHANGE_APPLY_DEBOUNCE: 50, -}; - -export const defaultDashboardState: DashboardState = { - viewMode: ViewMode.EDIT, // new dashboards start in edit mode. - fullScreenMode: false, - timeRestore: false, - query: { query: '', language: 'kuery' }, - description: '', - filters: [], - panels: {}, - title: '', - tags: [], - options: { - useMargins: true, - syncColors: false, - syncCursor: true, - syncTooltips: false, - hidePanelTitles: false, - }, -}; +export const LANDING_PAGE_PATH = '/list'; +export const CREATE_NEW_DASHBOARD_URL = '/create'; +export const VIEW_DASHBOARD_URL = '/view'; +export const PRINT_DASHBOARD_URL = '/print'; export const getFullPath = (aliasId?: string, id?: string) => `/app/dashboards#${createDashboardEditUrl(aliasId || id)}`; @@ -61,14 +28,57 @@ export const getFullEditPath = (id?: string, editMode?: boolean) => { export function createDashboardEditUrl(id?: string, editMode?: boolean) { if (!id) { - return `${DashboardConstants.CREATE_NEW_DASHBOARD_URL}`; + return `${CREATE_NEW_DASHBOARD_URL}`; } const edit = editMode ? `?${DASHBOARD_STATE_STORAGE_KEY}=(viewMode:edit)` : ''; - return `${DashboardConstants.VIEW_DASHBOARD_URL}/${id}${edit}`; + return `${VIEW_DASHBOARD_URL}/${id}${edit}`; } export function createDashboardListingFilterUrl(filter: string | undefined) { - return filter - ? `${DashboardConstants.LANDING_PAGE_PATH}?filter="${filter}"` - : DashboardConstants.LANDING_PAGE_PATH; + return filter ? `${LANDING_PAGE_PATH}?filter="${filter}"` : LANDING_PAGE_PATH; } + +// ------------------------------------------------------------------ +// Telemetry & Events +// ------------------------------------------------------------------ +export const DASHBOARD_LOADED_EVENT = 'dashboard_loaded'; +export const DASHBOARD_UI_METRIC_ID = 'dashboard'; + +// ------------------------------------------------------------------ +// IDs +// ------------------------------------------------------------------ +export const DASHBOARD_APP_ID = 'dashboards'; +export const LEGACY_DASHBOARD_APP_ID = 'dashboard'; +export const SEARCH_SESSION_ID = 'searchSessionId'; +export const DASHBOARD_SAVED_OBJECT_TYPE = 'dashboard'; + +// ------------------------------------------------------------------ +// Grid +// ------------------------------------------------------------------ +export const DEFAULT_PANEL_HEIGHT = 15; +export const DASHBOARD_GRID_HEIGHT = 20; +export const DASHBOARD_GRID_COLUMN_COUNT = 48; +export const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2; + +export const CHANGE_CHECK_DEBOUNCE = 100; + +// ------------------------------------------------------------------ +// Default State +// ------------------------------------------------------------------ +export const DEFAULT_DASHBOARD_INPUT: Omit = { + viewMode: ViewMode.EDIT, // new dashboards start in edit mode. + timeRestore: false, + query: { query: '', language: 'kuery' }, + description: '', + filters: [], + panels: {}, + title: '', + tags: [], + + // options + useMargins: true, + syncColors: false, + syncCursor: true, + syncTooltips: false, + hidePanelTitles: false, +}; diff --git a/src/plugins/dashboard/public/dashboard_container/_dashboard_container.scss b/src/plugins/dashboard/public/dashboard_container/_dashboard_container.scss new file mode 100644 index 000000000000..c4107d3f235b --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/_dashboard_container.scss @@ -0,0 +1,40 @@ +@import '../../../embeddable/public/variables'; + +@import './component/grid/index'; +@import './component/panel/index'; +@import './component/viewport/index'; + +.dashboardViewport { + flex: 1; + display: flex; + flex-direction: column; +} + +.dashboardViewport--loading { + justify-content: center; + align-items: center; +} + +.dshStartScreen { + text-align: center; +} + +.dshStartScreen__pageContent { + padding: $euiSizeXXL; +} + +.dshStartScreen__panelDesc { + max-width: 260px; + margin: 0 auto; +} + +.dshEmptyWidget { + background-color: $euiColorLightestShade; + border: $euiBorderThin; + border-style: dashed; + border-radius: $euiBorderRadius; + padding: $euiSizeXXL * 2; + max-width: 400px; + margin-left: $euiSizeS; + text-align: center; +} \ No newline at end of file diff --git a/src/plugins/dashboard/public/dashboard_container/_dashboard_container_strings.ts b/src/plugins/dashboard/public/dashboard_container/_dashboard_container_strings.ts new file mode 100644 index 000000000000..5bd6a24d8130 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/_dashboard_container_strings.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +/* + Empty Screen +*/ +export const emptyScreenStrings = { + getEmptyDashboardTitle: () => + i18n.translate('dashboard.emptyDashboardTitle', { + defaultMessage: 'This dashboard is empty.', + }), + getEmptyDashboardAdditionalPrivilege: () => + i18n.translate('dashboard.emptyDashboardAdditionalPrivilege', { + defaultMessage: 'You need additional privileges to edit this dashboard.', + }), + getFillDashboardTitle: () => + i18n.translate('dashboard.fillDashboardTitle', { + defaultMessage: 'This dashboard is empty. Let\u2019s fill it up!', + }), + getHowToStartWorkingOnNewDashboardDescription: () => + i18n.translate('dashboard.howToStartWorkingOnNewDashboardDescription', { + defaultMessage: 'Click edit in the menu bar above to start adding panels.', + }), + getHowToStartWorkingOnNewDashboardEditLinkAriaLabel: () => + i18n.translate('dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel', { + defaultMessage: 'Edit dashboard', + }), + getEmptyWidgetTitle: () => + i18n.translate('dashboard.emptyWidget.addPanelTitle', { + defaultMessage: 'Add your first visualization', + }), + getEmptyWidgetDescription: () => + i18n.translate('dashboard.emptyWidget.addPanelDescription', { + defaultMessage: 'Create content that tells a story about your data.', + }), +}; + +export const dashboardSaveToastStrings = { + getSuccessString: (dashTitle: string) => + i18n.translate('dashboard.dashboardWasSavedSuccessMessage', { + defaultMessage: `Dashboard '{dashTitle}' was saved`, + values: { dashTitle }, + }), + getFailureString: (dashTitle: string, errorMessage: string) => + i18n.translate('dashboard.dashboardWasNotSavedDangerMessage', { + defaultMessage: `Dashboard '{dashTitle}' was not saved. Error: {errorMessage}`, + values: { + dashTitle, + errorMessage, + }, + }), +}; + +export const dashboardSavedObjectErrorStrings = { + getDashboardGridError: (message: string) => + i18n.translate('dashboard.loadingError.dashboardGridErrorMessage', { + defaultMessage: 'Unable to load dashboard: {message}', + values: { message }, + }), + getErrorDeletingDashboardToast: () => + i18n.translate('dashboard.deleteError.toastDescription', { + defaultMessage: 'Error encountered while deleting dashboard', + }), +}; + +export const panelStorageErrorStrings = { + getPanelsGetError: (message: string) => + i18n.translate('dashboard.panelStorageError.getError', { + defaultMessage: 'Error encountered while fetching unsaved changes: {message}', + values: { message }, + }), + getPanelsSetError: (message: string) => + i18n.translate('dashboard.panelStorageError.setError', { + defaultMessage: 'Error encountered while setting unsaved changes: {message}', + values: { message }, + }), + getPanelsClearError: (message: string) => + i18n.translate('dashboard.panelStorageError.clearError', { + defaultMessage: 'Error encountered while clearing unsaved changes: {message}', + values: { message }, + }), +}; diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap similarity index 98% rename from src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap rename to src/plugins/dashboard/public/dashboard_container/component/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 6ccb34d7f52c..bfa152c38553 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -2,7 +2,7 @@ exports[`DashboardEmptyScreen renders correctly with edit mode 1`] = `
{ const DashboardServicesProvider = pluginServices.getContextProvider(); diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.tsx b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx similarity index 97% rename from src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.tsx rename to src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx index 9c5a98388350..74d02cd4234b 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx @@ -18,8 +18,8 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import { emptyScreenStrings } from '../../../dashboard_strings'; import { pluginServices } from '../../../services/plugin_services'; +import { emptyScreenStrings } from '../../_dashboard_container_strings'; export interface DashboardEmptyScreenProps { isEditMode?: boolean; @@ -83,7 +83,7 @@ export function DashboardEmptyScreen({ isEditMode }: DashboardEmptyScreenProps) ); const viewMode = page(emptyScreenStrings.getFillDashboardTitle(), true); const editMode = ( -
+
diff --git a/src/plugins/dashboard/public/application/embeddable/grid/_dashboard_grid.scss b/src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/grid/_dashboard_grid.scss rename to src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss diff --git a/src/plugins/dashboard/public/application/embeddable/grid/_index.scss b/src/plugins/dashboard/public/dashboard_container/component/grid/_index.scss similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/grid/_index.scss rename to src/plugins/dashboard/public/dashboard_container/component/grid/_index.scss diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx similarity index 67% rename from src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx rename to src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx index 6972a521026b..dffbda8bee1e 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx @@ -9,23 +9,20 @@ // @ts-ignore import sizeMe from 'react-sizeme'; import React from 'react'; -import { skip } from 'rxjs/operators'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; - -import { DashboardGrid, DashboardGridProps } from './dashboard_grid'; -import { DashboardContainer } from '../dashboard_container'; -import { getSampleDashboardInput } from '../../test_helpers'; import { ContactCardEmbeddableFactory, CONTACT_CARD_EMBEDDABLE, } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; + import { pluginServices } from '../../../services/plugin_services'; +import { DashboardGrid, DashboardGridProps } from './dashboard_grid'; +import { getSampleDashboardInput, mockDashboardReduxEmbeddableTools } from '../../../mocks'; -let dashboardContainer: DashboardContainer | undefined; const DashboardServicesProvider = pluginServices.getContextProvider(); -function prepare(props?: Partial) { +async function prepare(props?: Partial) { const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); pluginServices.getServices().embeddable.getEmbeddableFactory = jest .fn() @@ -45,13 +42,12 @@ function prepare(props?: Partial) { }, }, }); - dashboardContainer = new DashboardContainer(initialInput); - const defaultTestProps: DashboardGridProps = { - container: dashboardContainer, - intl: null as any, - }; + const dashboardMock = await mockDashboardReduxEmbeddableTools({ explicitInput: initialInput }); + const defaultTestProps: DashboardGridProps = {}; return { + tools: dashboardMock.tools, + dashboardContainer: dashboardMock.dashboardContainer, props: Object.assign(defaultTestProps, props), }; } @@ -67,12 +63,14 @@ afterAll(() => { }); // unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -test.skip('renders DashboardGrid', () => { - const { props } = prepare(); +test.skip('renders DashboardGrid', async () => { + const { props, tools } = await prepare(); const component = mountWithIntl( - + + + ); const panelElements = component.find('EmbeddableChildPanel'); @@ -80,47 +78,53 @@ test.skip('renders DashboardGrid', () => { }); // unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -test.skip('renders DashboardGrid with no visualizations', () => { - const { props } = prepare(); +test.skip('renders DashboardGrid with no visualizations', async () => { + const { props, tools, dashboardContainer } = await prepare(); const component = mountWithIntl( - + + + ); - props.container.updateInput({ panels: {} }); + dashboardContainer.updateInput({ panels: {} }); component.update(); expect(component.find('EmbeddableChildPanel').length).toBe(0); }); // unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -test.skip('DashboardGrid removes panel when removed from container', () => { - const { props } = prepare(); +test.skip('DashboardGrid removes panel when removed from container', async () => { + const { props, tools, dashboardContainer } = await prepare(); const component = mountWithIntl( - + + + ); - const originalPanels = props.container.getInput().panels; + const originalPanels = dashboardContainer.getInput().panels; const filteredPanels = { ...originalPanels }; delete filteredPanels['1']; - props.container.updateInput({ panels: filteredPanels }); + dashboardContainer.updateInput({ panels: filteredPanels }); component.update(); const panelElements = component.find('EmbeddableChildPanel'); expect(panelElements.length).toBe(1); }); // unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -test.skip('DashboardGrid renders expanded panel', () => { - const { props } = prepare(); +test.skip('DashboardGrid renders expanded panel', async () => { + const { props, tools, dashboardContainer } = await prepare(); const component = mountWithIntl( - + + + ); - props.container.updateInput({ expandedPanelId: '1' }); + dashboardContainer.setExpandedPanelId('1'); component.update(); // Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized. expect(component.find('EmbeddableChildPanel').length).toBe(2); @@ -129,7 +133,7 @@ test.skip('DashboardGrid renders expanded panel', () => { (component.find('DashboardGridUi').state() as { expandedPanelId?: string }).expandedPanelId ).toBe('1'); - props.container.updateInput({ expandedPanelId: undefined }); + dashboardContainer.setExpandedPanelId(); component.update(); expect(component.find('EmbeddableChildPanel').length).toBe(2); @@ -137,24 +141,3 @@ test.skip('DashboardGrid renders expanded panel', () => { (component.find('DashboardGridUi').state() as { expandedPanelId?: string }).expandedPanelId ).toBeUndefined(); }); - -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -test.skip('DashboardGrid unmount unsubscribes', (done) => { - const { props } = prepare(); - const component = mountWithIntl( - - - - ); - - component.unmount(); - - props.container - .getInput$() - .pipe(skip(1)) - .subscribe(() => { - done(); - }); - - props.container.updateInput({ expandedPanelId: '1' }); -}); 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 new file mode 100644 index 000000000000..4ef20b6f2d5b --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx @@ -0,0 +1,239 @@ +/* + * 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 _ from 'lodash'; +import sizeMe from 'react-sizeme'; +import classNames from 'classnames'; +import 'react-resizable/css/styles.css'; +import 'react-grid-layout/css/styles.css'; +import React, { useCallback, useMemo, useRef } from 'react'; +import ReactGridLayout, { Layout, ReactGridLayoutProps } from 'react-grid-layout'; + +import { ViewMode, EmbeddablePhaseEvent } from '@kbn/embeddable-plugin/public'; + +import { DashboardPanelState } from '../../../../common'; +import { DashboardLoadedEventStatus } from '../../types'; +import { DashboardGridItem } from './dashboard_grid_item'; +import { DashboardLoadedInfo } from '../../embeddable/dashboard_container'; +import { useDashboardContainerContext } from '../../dashboard_container_renderer'; +import { DASHBOARD_GRID_COLUMN_COUNT, DASHBOARD_GRID_HEIGHT } from '../../../dashboard_constants'; +import { getPanelLayoutsAreEqual } from '../../embeddable/integrations/diff_state/dashboard_diffing_utils'; + +let lastValidGridSize = 0; + +/** + * This is a fix for a bug that stopped the browser window from automatically scrolling down when panels were made + * taller than the current grid. + * see https://github.com/elastic/kibana/issues/14710. + */ +function ensureWindowScrollsToBottom(event: { clientY: number; pageY: number }) { + // The buffer is to handle the case where the browser is maximized and it's impossible for the mouse to move below + // the screen, out of the window. see https://github.com/elastic/kibana/issues/14737 + const WINDOW_BUFFER = 10; + if (event.clientY > window.innerHeight - WINDOW_BUFFER) { + window.scrollTo(0, event.pageY + WINDOW_BUFFER - window.innerHeight); + } +} + +function ResponsiveGrid({ + size, + isViewMode, + layout, + onLayoutChange, + children, + maximizedPanelId, + useMargins, +}: { + size: { width: number }; + isViewMode: boolean; + layout: Layout[]; + onLayoutChange: ReactGridLayoutProps['onLayoutChange']; + children: JSX.Element[]; + maximizedPanelId?: string; + useMargins: boolean; +}) { + // This is to prevent a bug where view mode changes when the panel is expanded. View mode changes will trigger + // the grid to re-render, but when a panel is expanded, the size will be 0. Minimizing the panel won't cause the + // grid to re-render so it'll show a grid with a width of 0. + lastValidGridSize = size.width > 0 ? size.width : lastValidGridSize; + const classes = classNames({ + 'dshLayout--viewing': isViewMode, + 'dshLayout--editing': !isViewMode, + 'dshLayout-isMaximizedPanel': maximizedPanelId !== undefined, + 'dshLayout-withoutMargins': !useMargins, + }); + + const MARGINS = useMargins ? 8 : 0; + // We can't take advantage of isDraggable or isResizable due to performance concerns: + // https://github.com/STRML/react-grid-layout/issues/240 + return ( + ensureWindowScrollsToBottom(event)} + > + {children} + + ); +} + +// Using sizeMe sets up the grid to be re-rendered automatically not only when the window size changes, but also +// when the container size changes, so it works for Full Screen mode switches. +const config = { monitorWidth: true }; +const ResponsiveSizedGrid = sizeMe(config)(ResponsiveGrid); + +interface PanelLayout extends Layout { + i: string; +} + +interface DashboardPerformanceTracker { + panelIds: Record>; + loadStartTime: number; + lastTimeToData: number; + status: DashboardLoadedEventStatus; + doneCount: number; +} + +const defaultPerformanceTracker: DashboardPerformanceTracker = { + panelIds: {}, + loadStartTime: performance.now(), + lastTimeToData: 0, + status: 'done', + doneCount: 0, +}; + +export interface DashboardGridProps { + onDataLoaded?: (data: DashboardLoadedInfo) => void; +} + +export const DashboardGrid = ({ onDataLoaded }: DashboardGridProps) => { + const { + actions: { setPanels }, + useEmbeddableDispatch, + useEmbeddableSelector: select, + } = useDashboardContainerContext(); + const dispatch = useEmbeddableDispatch(); + + const panels = select((state) => state.explicitInput.panels); + const viewMode = select((state) => state.explicitInput.viewMode); + const useMargins = select((state) => state.explicitInput.useMargins); + const expandedPanelId = select((state) => state.componentState.expandedPanelId); + + const layout = useMemo(() => Object.values(panels).map((panel) => panel.gridData), [panels]); + const panelsInOrder = useMemo( + () => Object.keys(panels).map((key: string) => panels[key]), + [panels] + ); + + // reset performance tracker on each render. + const performanceRefs = useRef(defaultPerformanceTracker); + performanceRefs.current = defaultPerformanceTracker; + + const onPanelStatusChange = useCallback( + (info: EmbeddablePhaseEvent) => { + if (!onDataLoaded) return; + + if (performanceRefs.current.panelIds[info.id] === undefined || info.status === 'loading') { + performanceRefs.current.panelIds[info.id] = {}; + } else if (info.status === 'error') { + performanceRefs.current.status = 'error'; + } else if (info.status === 'loaded') { + performanceRefs.current.lastTimeToData = performance.now(); + } + + performanceRefs.current.panelIds[info.id][info.status] = performance.now(); + + if (info.status === 'error' || info.status === 'rendered') { + performanceRefs.current.doneCount++; + if (performanceRefs.current.doneCount === panelsInOrder.length) { + const doneTime = performance.now(); + const data: DashboardLoadedInfo = { + timeToData: + (performanceRefs.current.lastTimeToData || doneTime) - + performanceRefs.current.loadStartTime, + timeToDone: doneTime - performanceRefs.current.loadStartTime, + numOfPanels: panelsInOrder.length, + status, + }; + onDataLoaded(data); + } + } + }, + [onDataLoaded, panelsInOrder] + ); + + const onLayoutChange = useCallback( + (newLayout: PanelLayout[]) => { + const updatedPanels: { [key: string]: DashboardPanelState } = newLayout.reduce( + (updatedPanelsAcc, panelLayout) => { + updatedPanelsAcc[panelLayout.i] = { + ...panels[panelLayout.i], + gridData: _.pick(panelLayout, ['x', 'y', 'w', 'h', 'i']), + }; + return updatedPanelsAcc; + }, + {} as { [key: string]: DashboardPanelState } + ); + // onLayoutChange gets called by react grid layout a lot more than it should, so only dispatch the updated panels if the layout has actually changed + if (!getPanelLayoutsAreEqual(panels, updatedPanels)) { + dispatch(setPanels(updatedPanels)); + } + }, + [dispatch, panels, setPanels] + ); + + const dashboardPanels = useMemo(() => { + panelsInOrder.sort((panelA, panelB) => { + if (panelA.gridData.y === panelB.gridData.y) { + return panelA.gridData.x - panelB.gridData.x; + } else { + return panelA.gridData.y - panelB.gridData.y; + } + }); + + return panelsInOrder.map(({ explicitInput, type }, index) => ( + + )); + }, [expandedPanelId, panelsInOrder, onPanelStatusChange]); + + // in print mode, dashboard layout is not controlled by React Grid Layout + if (viewMode === ViewMode.PRINT) { + return <>{dashboardPanels}; + } + + return ( + + {dashboardPanels} + + ); +}; diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid_item.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx similarity index 90% rename from src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid_item.tsx rename to src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx index 8b12d1f574fb..6e09254fed0d 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid_item.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx @@ -16,18 +16,16 @@ import { ViewMode, } from '@kbn/embeddable-plugin/public'; -import { DashboardPanelState } from '../types'; -import { DashboardContainer } from '..'; +import { DashboardPanelState } from '../../../../common'; import { pluginServices } from '../../../services/plugin_services'; +import { useDashboardContainerContext } from '../../dashboard_container_renderer'; -type PanelProps = Pick; type DivProps = Pick, 'className' | 'style' | 'children'>; -interface Props extends PanelProps, DivProps { +interface Props extends DivProps { id: DashboardPanelState['explicitInput']['id']; index?: number; type: DashboardPanelState['type']; - container: DashboardContainer; focusedPanelId?: string; expandedPanelId?: string; key: string; @@ -38,7 +36,6 @@ interface Props extends PanelProps, DivProps { const Item = React.forwardRef( ( { - container, expandedPanelId, focusedPanelId, id, @@ -58,6 +55,7 @@ const Item = React.forwardRef( const { embeddable: { EmbeddablePanel: PanelComponent }, } = pluginServices.getServices(); + const { embeddableInstance: container } = useDashboardContainerContext(); const expandPanel = expandedPanelId !== undefined && expandedPanelId === id; const hidePanel = expandedPanelId !== undefined && expandedPanelId !== id; @@ -135,7 +133,9 @@ export const DashboardGridItem: FC = (props: Props) => { settings: { isProjectEnabledInLabs }, } = pluginServices.getServices(); - const isPrintMode = props.container.getInput().viewMode === ViewMode.PRINT; + const { useEmbeddableSelector: select } = useDashboardContainerContext(); + + const isPrintMode = select((state) => state.explicitInput.viewMode) === ViewMode.PRINT; const isEnabled = !isPrintMode && isProjectEnabledInLabs('labs:dashboard:deferBelowFold'); return isEnabled ? : ; diff --git a/src/plugins/dashboard/public/application/embeddable/grid/index.ts b/src/plugins/dashboard/public/dashboard_container/component/grid/index.ts similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/grid/index.ts rename to src/plugins/dashboard/public/dashboard_container/component/grid/index.ts diff --git a/src/plugins/dashboard/public/application/embeddable/panel/_dashboard_panel.scss b/src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/panel/_dashboard_panel.scss rename to src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss diff --git a/src/plugins/dashboard/public/application/embeddable/panel/_index.scss b/src/plugins/dashboard/public/dashboard_container/component/panel/_index.scss similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/panel/_index.scss rename to src/plugins/dashboard/public/dashboard_container/component/panel/_index.scss diff --git a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.test.ts b/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.test.ts similarity index 98% rename from src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.test.ts rename to src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.test.ts index 4c926675e1e9..acfec6de31d0 100644 --- a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.test.ts @@ -6,10 +6,11 @@ * 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 { DashboardPanelState } from '../types'; + import { createPanelState } from './create_panel_state'; interface TestInput extends EmbeddableInput { diff --git a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.ts b/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.ts similarity index 96% rename from src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.ts rename to src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.ts index e5d4f69c914c..8f060f26cfe5 100644 --- a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.ts +++ b/src/plugins/dashboard/public/dashboard_container/component/panel/create_panel_state.ts @@ -7,13 +7,14 @@ */ import { PanelState, EmbeddableInput } from '@kbn/embeddable-plugin/public'; -import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../../dashboard_constants'; -import { DashboardPanelState } from '../types'; + 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. diff --git a/src/plugins/dashboard/public/application/embeddable/panel/dashboard_panel_placement.ts b/src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/panel/dashboard_panel_placement.ts rename to src/plugins/dashboard/public/dashboard_container/component/panel/dashboard_panel_placement.ts diff --git a/src/plugins/dashboard/public/application/embeddable/panel/index.ts b/src/plugins/dashboard/public/dashboard_container/component/panel/index.ts similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/panel/index.ts rename to src/plugins/dashboard/public/dashboard_container/component/panel/index.ts diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/README.md b/src/plugins/dashboard/public/dashboard_container/component/viewport/README.md similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/viewport/README.md rename to src/plugins/dashboard/public/dashboard_container/component/viewport/README.md diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss b/src/plugins/dashboard/public/dashboard_container/component/viewport/_dashboard_viewport.scss similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss rename to src/plugins/dashboard/public/dashboard_container/component/viewport/_dashboard_viewport.scss diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/_index.scss b/src/plugins/dashboard/public/dashboard_container/component/viewport/_index.scss similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/viewport/_index.scss rename to src/plugins/dashboard/public/dashboard_container/component/viewport/_index.scss diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/_print_viewport.scss b/src/plugins/dashboard/public/dashboard_container/component/viewport/_print_viewport.scss similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/viewport/_print_viewport.scss rename to src/plugins/dashboard/public/dashboard_container/component/viewport/_print_viewport.scss 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 new file mode 100644 index 000000000000..607966a5c0b3 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx @@ -0,0 +1,110 @@ +/* + * 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, { useEffect, useRef } from 'react'; + +import { withSuspense } from '@kbn/shared-ux-utility'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen'; +import { CalloutProps, LazyControlsCallout } from '@kbn/controls-plugin/public'; + +import { DashboardGrid } from '../grid'; +import { pluginServices } from '../../../services/plugin_services'; +import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen'; +import { useDashboardContainerContext } from '../../dashboard_container_renderer'; +import { DashboardLoadedInfo } from '../../embeddable/dashboard_container'; + +const ControlsCallout = withSuspense(LazyControlsCallout); + +export const DashboardViewport = ({ + onDataLoaded, +}: { + onDataLoaded?: (data: DashboardLoadedInfo) => void; +}) => { + const { + settings: { isProjectEnabledInLabs, uiSettings }, + } = pluginServices.getServices(); + const controlsRoot = useRef(null); + + const { + useEmbeddableDispatch, + useEmbeddableSelector: select, + actions: { setFullScreenMode }, + embeddableInstance: dashboardContainer, + } = useDashboardContainerContext(); + const dispatch = useEmbeddableDispatch(); + + /** + * Render Control group + */ + const controlGroup = dashboardContainer.controlGroup; + useEffect(() => { + if (controlGroup && controlsRoot.current) controlGroup.render(controlsRoot.current); + }, [controlGroup]); + + const panelCount = Object.keys(select((state) => state.explicitInput.panels)).length; + const controlCount = Object.keys( + select((state) => state.explicitInput.controlGroupInput?.panels) ?? {} + ).length; + + const viewMode = select((state) => state.explicitInput.viewMode); + const dashboardTitle = select((state) => state.explicitInput.title); + const useMargins = select((state) => state.explicitInput.useMargins); + const description = select((state) => state.explicitInput.description); + const isFullScreenMode = select((state) => state.componentState.fullScreenMode); + const isEmbeddedExternally = select((state) => state.componentState.isEmbeddedExternally); + + const controlsEnabled = isProjectEnabledInLabs('labs:dashboard:dashboardControls'); + const hideAnnouncements = Boolean(uiSettings.get('hideAnnouncements')); + + return ( + <> + {controlsEnabled && controlGroup ? ( + <> + {!hideAnnouncements && + viewMode === ViewMode.EDIT && + panelCount !== 0 && + controlCount === 0 ? ( + { + return controlGroup && controlGroup.getCreateControlButton('callout'); + }} + /> + ) : null} + + {viewMode !== ViewMode.PRINT && ( +
0 ? 'dshDashboardViewport-controls' : ''} + ref={controlsRoot} + /> + )} + + ) : null} +
+ {isFullScreenMode && ( + dispatch(setFullScreenMode(false))} + toggleChrome={!isEmbeddedExternally} + /> + )} + {panelCount === 0 && ( +
+ +
+ )} + +
+ + ); +}; diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/print_media/README.md b/src/plugins/dashboard/public/dashboard_container/component/viewport/print_media/README.md similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/viewport/print_media/README.md rename to src/plugins/dashboard/public/dashboard_container/component/viewport/print_media/README.md diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/print_media/styling/_index.scss b/src/plugins/dashboard/public/dashboard_container/component/viewport/print_media/styling/_index.scss similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/viewport/print_media/styling/_index.scss rename to src/plugins/dashboard/public/dashboard_container/component/viewport/print_media/styling/_index.scss diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/print_media/styling/_vars.scss b/src/plugins/dashboard/public/dashboard_container/component/viewport/print_media/styling/_vars.scss similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/viewport/print_media/styling/_vars.scss rename to src/plugins/dashboard/public/dashboard_container/component/viewport/print_media/styling/_vars.scss diff --git a/src/plugins/dashboard/public/dashboard_container/dashboard_container_renderer.tsx b/src/plugins/dashboard/public/dashboard_container/dashboard_container_renderer.tsx new file mode 100644 index 000000000000..1d0cf7cad6a7 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/dashboard_container_renderer.tsx @@ -0,0 +1,126 @@ +/* + * 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_container.scss'; + +import uuid from 'uuid'; +import classNames from 'classnames'; +import { EuiLoadingElastic } from '@elastic/eui'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; + +import { + DashboardContainerFactory, + DashboardContainerFactoryDefinition, + DashboardCreationOptions, +} from './embeddable/dashboard_container_factory'; +import { DASHBOARD_CONTAINER_TYPE } from '..'; +import { DashboardReduxState } from './types'; +import { pluginServices } from '../services/plugin_services'; +import { DEFAULT_DASHBOARD_INPUT } from '../dashboard_constants'; +import { DashboardContainer } from './embeddable/dashboard_container'; +import { dashboardContainerReducers } from './state/dashboard_container_reducers'; + +export interface DashboardContainerRendererProps { + savedObjectId?: string; + getCreationOptions?: () => DashboardCreationOptions; + onDashboardContainerLoaded?: (dashboardContainer: DashboardContainer) => void; +} + +export const DashboardContainerRenderer = ({ + savedObjectId, + getCreationOptions, + onDashboardContainerLoaded, +}: DashboardContainerRendererProps) => { + const { + embeddable, + screenshotMode: { isScreenshotMode }, + } = pluginServices.getServices(); + + const dashboardRoot = useRef(null); + const [dashboardIdToBuild, setDashboardIdToBuild] = useState(savedObjectId); + const [dashboardContainer, setDashboardContainer] = useState(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // check if dashboard container is expecting id change... if not, update dashboardIdToBuild to force it to rebuild the container. + if (!dashboardContainer) return; + if (!dashboardContainer.isExpectingIdChange()) setDashboardIdToBuild(savedObjectId); + + // Disabling exhaustive deps because this useEffect should only be triggered when the savedObjectId changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [savedObjectId]); + + const id = useMemo(() => uuid.v4(), []); + + useEffect(() => { + let canceled = false; + let destroyContainer: () => void; + + (async () => { + const creationOptions = getCreationOptions?.(); + const dashboardFactory = embeddable.getEmbeddableFactory( + DASHBOARD_CONTAINER_TYPE + ) as DashboardContainerFactory & { create: DashboardContainerFactoryDefinition['create'] }; + const container = (await dashboardFactory?.create( + { + id, + ...DEFAULT_DASHBOARD_INPUT, + ...creationOptions?.initialInput, + savedObjectId: dashboardIdToBuild, + }, + undefined, + creationOptions + )) as DashboardContainer; + + await container.untilInitialized(); + if (canceled) { + container.destroy(); + return; + } + + setLoading(false); + if (dashboardRoot.current) { + container.render(dashboardRoot.current); + } + onDashboardContainerLoaded?.(container); + setDashboardContainer(container); + + destroyContainer = () => container.destroy(); + })(); + return () => { + canceled = true; + destroyContainer?.(); + }; + // Disabling exhaustive deps because embeddable should only be created when the dashboardIdToBuild changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dashboardIdToBuild]); + + const viewportClasses = classNames( + 'dashboardViewport', + { 'dashboardViewport--screenshotMode': isScreenshotMode() }, + { 'dashboardViewport--loading': loading } + ); + return ( +
+ {loading ? :
} +
+ ); +}; + +export const useDashboardContainerContext = () => + useReduxEmbeddableContext< + DashboardReduxState, + typeof dashboardContainerReducers, + DashboardContainer + >(); + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default DashboardContainerRenderer; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/add_panel_from_library.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/add_panel_from_library.ts new file mode 100644 index 000000000000..1cd2d05d9282 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/add_panel_from_library.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 { isErrorEmbeddable, openAddPanelFlyout } from '@kbn/embeddable-plugin/public'; +import { getSavedObjectFinder } from '@kbn/saved-objects-plugin/public'; + +import { pluginServices } from '../../../services/plugin_services'; +import { DashboardContainer } from '../dashboard_container'; + +export function addFromLibrary(this: DashboardContainer) { + const { + overlays, + notifications, + usageCollection, + settings: { uiSettings, theme }, + dashboardSavedObject: { savedObjectsClient }, + embeddable: { getEmbeddableFactories, getEmbeddableFactory }, + } = pluginServices.getServices(); + + if (isErrorEmbeddable(this)) return; + this.openOverlay( + openAddPanelFlyout({ + SavedObjectFinder: getSavedObjectFinder({ client: savedObjectsClient }, uiSettings), + reportUiCounter: usageCollection.reportUiCounter, + getAllFactories: getEmbeddableFactories, + getFactory: getEmbeddableFactory, + embeddable: this, + notifications, + overlays, + theme, + }) + ); +} diff --git a/src/plugins/dashboard/public/application/index.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/index.ts similarity index 55% rename from src/plugins/dashboard/public/application/index.ts rename to src/plugins/dashboard/public/dashboard_container/embeddable/api/index.ts index 52d90c0df7ac..fc58e3ad0aca 100644 --- a/src/plugins/dashboard/public/application/index.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/index.ts @@ -6,5 +6,7 @@ * Side Public License, v 1. */ -export * from './embeddable'; -export * from './actions'; +export { showOptions } from './show_options_popover'; +export { addFromLibrary } from './add_panel_from_library'; +export { runSaveAs, runQuickSave, runClone } from './run_save_functions'; +export { addOrUpdateEmbeddable, replacePanel, showPlaceholderUntil } from './panel_management'; diff --git a/src/plugins/dashboard/public/application/top_nav/__snapshots__/clone_modal.test.js.snap b/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/__snapshots__/clone_modal.test.js.snap similarity index 100% rename from src/plugins/dashboard/public/application/top_nav/__snapshots__/clone_modal.test.js.snap rename to src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/__snapshots__/clone_modal.test.js.snap diff --git a/src/plugins/dashboard/public/application/top_nav/__snapshots__/save_modal.test.js.snap b/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/__snapshots__/save_modal.test.js.snap similarity index 100% rename from src/plugins/dashboard/public/application/top_nav/__snapshots__/save_modal.test.js.snap rename to src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/__snapshots__/save_modal.test.js.snap diff --git a/src/plugins/dashboard/public/application/top_nav/clone_modal.test.js b/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/clone_modal.test.js similarity index 100% rename from src/plugins/dashboard/public/application/top_nav/clone_modal.test.js rename to src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/clone_modal.test.js diff --git a/src/plugins/dashboard/public/application/top_nav/clone_modal.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/clone_modal.tsx similarity index 100% rename from src/plugins/dashboard/public/application/top_nav/clone_modal.tsx rename to src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/clone_modal.tsx diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/options.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/options.tsx new file mode 100644 index 000000000000..b33e353881b0 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/options.tsx @@ -0,0 +1,92 @@ +/* + * 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 { EuiForm, EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { useDashboardContainerContext } from '../../../dashboard_container_renderer'; + +export const DashboardOptions = () => { + const { + useEmbeddableDispatch, + useEmbeddableSelector: select, + actions: { setUseMargins, setSyncCursor, setSyncColors, setSyncTooltips, setHidePanelTitles }, + } = useDashboardContainerContext(); + const dispatch = useEmbeddableDispatch(); + + const useMargins = select((state) => state.explicitInput.useMargins); + const syncColors = select((state) => state.explicitInput.syncColors); + const syncCursor = select((state) => state.explicitInput.syncCursor); + const syncTooltips = select((state) => state.explicitInput.syncTooltips); + const hidePanelTitles = select((state) => state.explicitInput.hidePanelTitles); + + return ( + + + dispatch(setUseMargins(event.target.checked))} + data-test-subj="dashboardMarginsCheckbox" + /> + + + + dispatch(setHidePanelTitles(!event.target.checked))} + data-test-subj="dashboardPanelTitlesCheckbox" + /> + + + <> + + dispatch(setSyncColors(event.target.checked))} + data-test-subj="dashboardSyncColorsCheckbox" + /> + + + dispatch(setSyncCursor(event.target.checked))} + data-test-subj="dashboardSyncCursorCheckbox" + /> + + + dispatch(setSyncTooltips(event.target.checked))} + data-test-subj="dashboardSyncTooltipsCheckbox" + /> + + + + + ); +}; diff --git a/src/plugins/dashboard/public/application/top_nav/save_modal.test.js b/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/save_modal.test.js similarity index 100% rename from src/plugins/dashboard/public/application/top_nav/save_modal.test.js rename to src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/save_modal.test.js diff --git a/src/plugins/dashboard/public/application/top_nav/save_modal.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/save_modal.tsx similarity index 93% rename from src/plugins/dashboard/public/application/top_nav/save_modal.tsx rename to src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/save_modal.tsx index 790bacf2f9aa..2f2254e054c2 100644 --- a/src/plugins/dashboard/public/application/top_nav/save_modal.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/save_modal.tsx @@ -6,14 +6,19 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import React, { Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiTextArea, EuiSwitch } from '@elastic/eui'; import { SavedObjectSaveModal } from '@kbn/saved-objects-plugin/public'; -import type { DashboardSaveOptions } from '../../types'; -import { pluginServices } from '../../services/plugin_services'; +import type { DashboardSaveOptions } from '../../../types'; +import { pluginServices } from '../../../../services/plugin_services'; + +/** + * TODO: Portable Dashboard followup, convert this to a functional component & use redux for the state. + * https://github.com/elastic/kibana/issues/147490 + */ interface Props { onSave: ({ diff --git a/src/plugins/dashboard/public/application/top_nav/show_clone_modal.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/show_clone_modal.tsx similarity index 91% rename from src/plugins/dashboard/public/application/top_nav/show_clone_modal.tsx rename to src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/show_clone_modal.tsx index 59d71fd4b2fa..6ebd6f771171 100644 --- a/src/plugins/dashboard/public/application/top_nav/show_clone_modal.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/overlays/show_clone_modal.tsx @@ -7,16 +7,17 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import ReactDOM from 'react-dom'; -import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n-react'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { DashboardCloneModal } from './clone_modal'; -import { pluginServices } from '../../services/plugin_services'; +import { pluginServices } from '../../../../services/plugin_services'; export interface ShowCloneModalProps { + onClose: () => void; onClone: ( newTitle: string, isTitleDuplicateConfirmed: boolean, @@ -25,7 +26,7 @@ export interface ShowCloneModalProps { title: string; } -export function showCloneModal({ onClone, title }: ShowCloneModalProps) { +export function showCloneModal({ onClone, title, onClose }: ShowCloneModalProps) { const { settings: { theme }, } = pluginServices.getServices(); @@ -34,6 +35,7 @@ export function showCloneModal({ onClone, title }: ShowCloneModalProps) { const closeModal = () => { ReactDOM.unmountComponentAtNode(container); document.body.removeChild(container); + onClose(); }; const onCloneConfirmed = async ( 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 new file mode 100644 index 000000000000..70e0f9df6e74 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/panel_management.ts @@ -0,0 +1,133 @@ +/* + * 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, + EmbeddableOutput, + IEmbeddable, + PanelState, +} from '@kbn/embeddable-plugin/public'; +import uuid from 'uuid'; + +import { + IPanelPlacementArgs, + PanelPlacementMethod, +} from '../../component/panel/dashboard_panel_placement'; +import { DashboardPanelState } from '../../../../common'; +import { createPanelState } from '../../component/panel'; +import { DashboardContainer } from '../dashboard_container'; +import { PLACEHOLDER_EMBEDDABLE } from '../../../placeholder_embeddable'; + +export async function addOrUpdateEmbeddable< + EEI extends EmbeddableInput = EmbeddableInput, + EEO extends EmbeddableOutput = EmbeddableOutput, + E extends IEmbeddable = IEmbeddable +>(this: DashboardContainer, type: string, explicitInput: Partial, embeddableId?: string) { + const idToReplace = embeddableId || explicitInput.id; + if (idToReplace && this.input.panels[idToReplace]) { + return this.replacePanel(this.input.panels[idToReplace], { + type, + explicitInput: { + ...explicitInput, + id: idToReplace, + }, + }); + } + return this.addNewEmbeddable(type, explicitInput); +} + +export async function replacePanel( + this: DashboardContainer, + previousPanelState: DashboardPanelState, + newPanelState: Partial, + generateNewId?: boolean +) { + let panels; + if (generateNewId) { + // replace panel can be called with generateNewId in order to totally destroy and recreate the embeddable + panels = { ...this.input.panels }; + delete panels[previousPanelState.explicitInput.id]; + const newId = uuid.v4(); + panels[newId] = { + ...previousPanelState, + ...newPanelState, + gridData: { + ...previousPanelState.gridData, + i: newId, + }, + explicitInput: { + ...newPanelState.explicitInput, + id: newId, + }, + }; + } else { + // Because the embeddable type can change, we have to operate at the container level here + panels = { + ...this.input.panels, + [previousPanelState.explicitInput.id]: { + ...previousPanelState, + ...newPanelState, + gridData: { + ...previousPanelState.gridData, + }, + explicitInput: { + ...newPanelState.explicitInput, + id: previousPanelState.explicitInput.id, + }, + }, + }; + } + + return this.updateInput({ + panels, + lastReloadRequestTime: new Date().getTime(), + }); +} + +export function showPlaceholderUntil( + this: DashboardContainer, + newStateComplete: Promise>, + placementMethod?: PanelPlacementMethod, + placementArgs?: TPlacementMethodArgs +): void { + const originalPanelState = { + type: PLACEHOLDER_EMBEDDABLE, + explicitInput: { + id: uuid.v4(), + disabledActions: [ + 'ACTION_CUSTOMIZE_PANEL', + 'CUSTOM_TIME_RANGE', + 'clonePanel', + 'replacePanel', + 'togglePanel', + ], + }, + } as PanelState; + + const { otherPanels, newPanel: placeholderPanelState } = createPanelState( + originalPanelState, + this.input.panels, + placementMethod, + placementArgs + ); + + this.updateInput({ + panels: { + ...otherPanels, + [placeholderPanelState.explicitInput.id]: placeholderPanelState, + }, + }); + + // wait until the placeholder is ready, then replace it with new panel + // this is useful as sometimes panels can load faster than the placeholder one (i.e. by value embeddables) + this.untilEmbeddableLoaded(originalPanelState.explicitInput.id) + .then(() => newStateComplete) + .then((newPanelState: Partial) => + this.replacePanel(placeholderPanelState, newPanelState) + ); +} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx new file mode 100644 index 000000000000..291960d0c909 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx @@ -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 React from 'react'; +import { batch } from 'react-redux'; +import { showSaveModal } from '@kbn/saved-objects-plugin/public'; + +import { DashboardSaveOptions, DashboardStateFromSaveModal } from '../../types'; +import { DashboardSaveModal } from './overlays/save_modal'; +import { DashboardContainer } from '../dashboard_container'; +import { showCloneModal } from './overlays/show_clone_modal'; +import { pluginServices } from '../../../services/plugin_services'; +import { DashboardContainerByValueInput } from '../../../../common'; +import { SaveDashboardReturn } from '../../../services/dashboard_saved_object/types'; + +export function runSaveAs(this: DashboardContainer) { + const { + data: { + query: { + timefilter: { timefilter }, + }, + }, + coreContext: { i18nContext }, + savedObjectsTagging: { hasApi: hasSavedObjectsTagging }, + dashboardSavedObject: { checkForDuplicateDashboardTitle, saveDashboardStateToSavedObject }, + } = pluginServices.getServices(); + + const { + getState, + dispatch, + actions: { setStateFromSaveModal, setLastSavedInput }, + } = this.getReduxEmbeddableTools(); + const { + explicitInput: currentState, + componentState: { lastSavedId }, + } = getState(); + + return new Promise((resolve) => { + const onSave = async ({ + newTags, + newTitle, + newDescription, + newCopyOnSave, + newTimeRestore, + onTitleDuplicate, + isTitleDuplicateConfirmed, + }: DashboardSaveOptions): Promise => { + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + saveAsCopy: newCopyOnSave, + }; + const stateFromSaveModal: DashboardStateFromSaveModal = { + title: newTitle, + tags: [] as string[], + description: newDescription, + timeRestore: newTimeRestore, + timeRange: newTimeRestore ? timefilter.getTime() : undefined, + refreshInterval: newTimeRestore ? timefilter.getRefreshInterval() : undefined, + }; + if (hasSavedObjectsTagging && newTags) { + // remove `hasSavedObjectsTagging` once the savedObjectsTagging service is optional + stateFromSaveModal.tags = newTags; + } + if ( + !(await checkForDuplicateDashboardTitle({ + title: newTitle, + onTitleDuplicate, + lastSavedTitle: currentState.title, + copyOnSave: newCopyOnSave, + isTitleDuplicateConfirmed, + })) + ) { + // do not save if title is duplicate and is unconfirmed + return {}; + } + const stateToSave: DashboardContainerByValueInput = { + ...currentState, + ...stateFromSaveModal, + }; + const saveResult = await saveDashboardStateToSavedObject({ + currentState: stateToSave, + saveOptions, + lastSavedId, + }); + + stateFromSaveModal.lastSavedId = saveResult.id; + if (saveResult.id) { + batch(() => { + dispatch(setStateFromSaveModal(stateFromSaveModal)); + dispatch(setLastSavedInput(stateToSave)); + }); + } + if (newCopyOnSave || !lastSavedId) this.expectIdChange(); + resolve(saveResult); + return saveResult; + }; + + const dashboardSaveModal = ( + resolve(undefined)} + timeRestore={currentState.timeRestore} + description={currentState.description ?? ''} + showCopyOnSave={lastSavedId ? true : false} + onSave={onSave} + /> + ); + this.clearOverlays(); + showSaveModal(dashboardSaveModal, i18nContext); + }); +} + +/** + * Save the current state of this dashboard to a saved object without showing any save modal. + */ +export async function runQuickSave(this: DashboardContainer) { + const { + dashboardSavedObject: { saveDashboardStateToSavedObject }, + } = pluginServices.getServices(); + + const { + getState, + dispatch, + actions: { setLastSavedInput }, + } = this.getReduxEmbeddableTools(); + const { + explicitInput: currentState, + componentState: { lastSavedId }, + } = getState(); + + const saveResult = await saveDashboardStateToSavedObject({ + lastSavedId, + currentState, + saveOptions: {}, + }); + dispatch(setLastSavedInput(currentState)); + + return saveResult; +} + +export async function runClone(this: DashboardContainer) { + const { + dashboardSavedObject: { saveDashboardStateToSavedObject, checkForDuplicateDashboardTitle }, + } = pluginServices.getServices(); + + const { + getState, + dispatch, + actions: { setTitle }, + } = this.getReduxEmbeddableTools(); + const { explicitInput: currentState } = getState(); + + return new Promise((resolve) => { + const onClone = async ( + newTitle: string, + isTitleDuplicateConfirmed: boolean, + onTitleDuplicate: () => void + ) => { + if ( + !(await checkForDuplicateDashboardTitle({ + title: newTitle, + onTitleDuplicate, + lastSavedTitle: currentState.title, + copyOnSave: true, + isTitleDuplicateConfirmed, + })) + ) { + // do not clone if title is duplicate and is unconfirmed + return {}; + } + const saveResult = await saveDashboardStateToSavedObject({ + saveOptions: { saveAsCopy: true }, + currentState: { ...currentState, title: newTitle }, + }); + + dispatch(setTitle(newTitle)); + resolve(saveResult); + this.expectIdChange(); + return saveResult.id ? { id: saveResult.id } : { error: saveResult.error }; + }; + showCloneModal({ onClone, title: currentState.title, onClose: () => resolve(undefined) }); + }); +} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/show_options_popover.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/show_options_popover.tsx new file mode 100644 index 000000000000..ebbd63bd6a93 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/show_options_popover.tsx @@ -0,0 +1,61 @@ +/* + * 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 { I18nProvider } from '@kbn/i18n-react'; +import { EuiWrappingPopover } from '@elastic/eui'; +import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; + +import { DashboardOptions } from './overlays/options'; +import { DashboardContainer } from '../dashboard_container'; +import { pluginServices } from '../../../services/plugin_services'; + +let isOpen = false; + +const container = document.createElement('div'); +const onClose = () => { + ReactDOM.unmountComponentAtNode(container); + isOpen = false; +}; + +export function showOptions(this: DashboardContainer, anchorElement: HTMLElement) { + const { + settings: { + theme: { theme$ }, + }, + } = pluginServices.getServices(); + + if (isOpen) { + onClose(); + return; + } + + isOpen = true; + const { Wrapper: DashboardReduxWrapper } = this.getReduxEmbeddableTools(); + + document.body.appendChild(container); + const element = ( + + + + + + + + + + ); + ReactDOM.render(element, container); +} diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx similarity index 97% rename from src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx rename to src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx index 7ad4b6432450..83b3b866bfb8 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx @@ -30,7 +30,7 @@ import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; import { createEditModeAction } from '@kbn/embeddable-plugin/public/lib/test_samples'; import { DashboardContainer } from './dashboard_container'; -import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; +import { getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks'; import { pluginServices } from '../../services/plugin_services'; import { ApplicationStart } from '@kbn/core-application-browser'; @@ -77,6 +77,7 @@ test('DashboardContainer initializes embeddables', (done) => { test('DashboardContainer.addNewEmbeddable', async () => { const container = new DashboardContainer(getSampleDashboardInput()); + await container.untilInitialized(); const embeddable = await container.addNewEmbeddable( CONTACT_CARD_EMBEDDABLE, { @@ -149,6 +150,7 @@ test('Container view mode change propagates to existing children', async () => { }, }); const container = new DashboardContainer(initialInput); + await container.untilInitialized(); const embeddable = await container.untilEmbeddableLoaded('123'); expect(embeddable.getInput().viewMode).toBe(ViewMode.VIEW); @@ -158,6 +160,7 @@ test('Container view mode change propagates to existing children', async () => { test('Container view mode change propagates to new children', async () => { const container = new DashboardContainer(getSampleDashboardInput()); + await container.untilInitialized(); const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, @@ -178,6 +181,7 @@ test('searchSessionId propagates to children', async () => { const container = new DashboardContainer( getSampleDashboardInput({ searchSessionId: searchSessionId1 }) ); + await container.untilInitialized(); const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, @@ -203,6 +207,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { const initialInput = getSampleDashboardInput({ viewMode: ViewMode.VIEW }); const container = new DashboardContainer(initialInput); + await container.untilInitialized(); const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx new file mode 100644 index 000000000000..f1654242fd88 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -0,0 +1,612 @@ +/* + * 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 { cloneDeep, omit } from 'lodash'; +import { BehaviorSubject, Subject, Subscription } from 'rxjs'; + +import { + lazyLoadReduxEmbeddablePackage, + ReduxEmbeddableTools, +} from '@kbn/presentation-util-plugin/public'; +import { + ViewMode, + Container, + type Embeddable, + type IEmbeddable, + type EmbeddableInput, + type EmbeddableOutput, + type EmbeddableFactory, +} from '@kbn/embeddable-plugin/public'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { Filter, TimeRange, Query } from '@kbn/es-query'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; +import type { RefreshInterval } from '@kbn/data-plugin/public'; +import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import type { ControlGroupContainer } from '@kbn/controls-plugin/public'; +import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public'; +import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen'; + +import { + runClone, + runSaveAs, + showOptions, + runQuickSave, + replacePanel, + addFromLibrary, + showPlaceholderUntil, + addOrUpdateEmbeddable, +} from './api'; +import { + DashboardPanelState, + DashboardContainerInput, + DashboardContainerByValueInput, +} from '../../../common'; +import { + startDiffingDashboardState, + startControlGroupIntegration, + startUnifiedSearchIntegration, + startSyncingDashboardDataViews, + startDashboardSearchSessionIntegration, + combineDashboardFiltersWithControlGroupFilters, + getUnsavedChanges, + keysNotConsideredUnsavedChanges, +} from './integrations'; +import { DASHBOARD_CONTAINER_TYPE } from '../..'; +import { createPanelState } from '../component/panel'; +import { pluginServices } from '../../services/plugin_services'; +import { DASHBOARD_LOADED_EVENT } from '../../dashboard_constants'; +import { DashboardCreationOptions } from './dashboard_container_factory'; +import { DashboardContainerOutput, DashboardReduxState } from '../types'; +import { DashboardAnalyticsService } from '../../services/analytics/types'; +import { DashboardViewport } from '../component/viewport/dashboard_viewport'; +import { dashboardContainerReducers } from '../state/dashboard_container_reducers'; +import { DashboardSavedObjectService } from '../../services/dashboard_saved_object/types'; +import { dashboardContainerInputIsByValue } from '../../../common/dashboard_container/type_guards'; + +export interface DashboardLoadedInfo { + timeToData: number; + timeToDone: number; + numOfPanels: number; + status: string; +} + +export interface InheritedChildInput { + filters: Filter[]; + query: Query; + timeRange?: TimeRange; + timeslice?: [number, number]; + refreshConfig?: RefreshInterval; + viewMode: ViewMode; + hidePanelTitles?: boolean; + id: string; + searchSessionId?: string; + syncColors?: boolean; + syncCursor?: boolean; + syncTooltips?: boolean; + executionContext?: KibanaExecutionContext; +} + +export class DashboardContainer extends Container { + public readonly type = DASHBOARD_CONTAINER_TYPE; + public controlGroup?: ControlGroupContainer; + + // Dashboard State + private onDestroyControlGroup?: () => void; + private subscriptions: Subscription = new Subscription(); + + private initialized$: BehaviorSubject = new BehaviorSubject(false); + private initialSavedDashboardId?: string; + + private reduxEmbeddableTools?: ReduxEmbeddableTools< + DashboardReduxState, + typeof dashboardContainerReducers + >; + + private domNode?: HTMLElement; + private overlayRef?: OverlayRef; + private allDataViews: DataView[] = []; + + // Services that are used in the Dashboard container code + private creationOptions?: DashboardCreationOptions; + private analyticsService: DashboardAnalyticsService; + private dashboardSavedObjectService: DashboardSavedObjectService; + private theme$; + private chrome; + + constructor( + initialInput: DashboardContainerInput, + parent?: Container, + creationOptions?: DashboardCreationOptions + ) { + // we won't initialize any embeddable children until after the dashboard is done initializing. + const readyToInitializeChildren$ = new Subject(); + const { + embeddable: { getEmbeddableFactory }, + } = pluginServices.getServices(); + + super( + { + ...initialInput, + }, + { embeddableLoaded: {} }, + getEmbeddableFactory, + parent, + { + readyToInitializeChildren$, + } + ); + + ({ + analytics: this.analyticsService, + dashboardSavedObject: this.dashboardSavedObjectService, + settings: { + theme: { theme$: this.theme$ }, + }, + chrome: this.chrome, + } = pluginServices.getServices()); + + this.initialSavedDashboardId = dashboardContainerInputIsByValue(this.input) + ? undefined + : this.input.savedObjectId; + this.creationOptions = creationOptions; + this.initializeDashboard(readyToInitializeChildren$, creationOptions); + } + + public getDashboardSavedObjectId() { + if (this.initialized$.value) { + return this.getReduxEmbeddableTools().getState().componentState.lastSavedId; + } + return this.initialSavedDashboardId; + } + + public getInputAsValueType = () => { + if (!dashboardContainerInputIsByValue(this.input)) { + throw new Error('cannot get input as value type until after dashboard input is unwrapped.'); + } + return this.getInput() as DashboardContainerByValueInput; + }; + + private async unwrapDashboardContainerInput(): Promise< + DashboardContainerByValueInput | undefined + > { + if (dashboardContainerInputIsByValue(this.input)) { + return this.input; + } + const unwrapResult = await this.dashboardSavedObjectService.loadDashboardStateFromSavedObject({ + id: this.input.savedObjectId, + }); + this.updateInput({ savedObjectId: undefined }); + if ( + !this.creationOptions?.validateLoadedSavedObject || + this.creationOptions.validateLoadedSavedObject(unwrapResult) + ) { + return unwrapResult.dashboardInput; + } + } + + private async initializeDashboard( + readyToInitializeChildren$: Subject, + creationOptions?: DashboardCreationOptions + ) { + const { + data: { dataViews }, + } = pluginServices.getServices(); + + const reduxEmbeddablePackagePromise = lazyLoadReduxEmbeddablePackage(); + const defaultDataViewAssignmentPromise = dataViews.getDefaultDataView(); + const dashboardStateUnwrapPromise = this.unwrapDashboardContainerInput(); + + const [reduxEmbeddablePackage, inputFromSavedObject, defaultDataView] = await Promise.all([ + reduxEmbeddablePackagePromise, + dashboardStateUnwrapPromise, + defaultDataViewAssignmentPromise, + ]); + + if (!defaultDataView) { + throw new Error('Dashboard requires at least one data view before it can be initialized.'); + } + + // inputFromSavedObject will only be undefined if the provided valiation function returns false. + if (!inputFromSavedObject) { + this.destroy(); + return; + } + + // Gather input from session storage if integration is used + let sessionStorageInput: Partial = {}; + if (creationOptions?.useSessionStorageIntegration) { + const { dashboardSessionStorage } = pluginServices.getServices(); + const sessionInput = dashboardSessionStorage.getState(this.initialSavedDashboardId); + if (sessionInput) sessionStorageInput = sessionInput; + } + + // Combine input from saved object with override input. + const initialInput: DashboardContainerByValueInput = cloneDeep({ + ...inputFromSavedObject, + ...sessionStorageInput, + ...creationOptions?.overrideInput, + }); + + // set up execution context + initialInput.executionContext = { + type: 'dashboard', + description: initialInput.title, + }; + + // set up unified search integration + if ( + creationOptions?.useUnifiedSearchIntegration && + creationOptions.unifiedSearchSettings?.kbnUrlStateStorage + ) { + const { kbnUrlStateStorage } = creationOptions.unifiedSearchSettings; + const initialTimeRange = startUnifiedSearchIntegration.bind(this)({ + initialInput, + kbnUrlStateStorage, + setCleanupFunction: (cleanup) => { + this.stopSyncingWithUnifiedSearch = cleanup; + }, + }); + if (initialTimeRange) initialInput.timeRange = initialTimeRange; + } + + // place the incoming embeddable if there is one + const incomingEmbeddable = creationOptions?.incomingEmbeddable; + if (incomingEmbeddable) { + initialInput.viewMode = ViewMode.EDIT; // view mode must always be edit to recieve an embeddable. + if ( + incomingEmbeddable.embeddableId && + Boolean(initialInput.panels[incomingEmbeddable.embeddableId]) + ) { + // this embeddable already exists, we will update the explicit input. + const panelToUpdate = initialInput.panels[incomingEmbeddable.embeddableId]; + const sameType = panelToUpdate.type === incomingEmbeddable.type; + + panelToUpdate.type = incomingEmbeddable.type; + panelToUpdate.explicitInput = { + // if the incoming panel is the same type as what was there before we can safely spread the old panel's explicit input + ...(sameType ? panelToUpdate.explicitInput : {}), + + ...incomingEmbeddable.input, + id: incomingEmbeddable.embeddableId, + + // maintain hide panel titles setting. + hidePanelTitles: panelToUpdate.explicitInput.hidePanelTitles, + }; + } else { + // otherwise this incoming embeddable is brand new and can be added via the default method after the dashboard container is created. + this.untilInitialized().then(() => + setTimeout( + () => this.addNewEmbeddable(incomingEmbeddable.type, incomingEmbeddable.input), + 1 // add embeddable on next update so that the state diff can pick it up. + ) + ); + } + } + + // start search sessions integration + if (creationOptions?.useSearchSessionsIntegration) { + const { initialSearchSessionId, stopSyncingDashboardSearchSessions } = + startDashboardSearchSessionIntegration.bind(this)( + creationOptions?.searchSessionSettings, + incomingEmbeddable + ); + initialInput.searchSessionId = initialSearchSessionId; + this.stopSyncingDashboardSearchSessions = stopSyncingDashboardSearchSessions; + } + + // update input so the redux embeddable tools get the unwrapped, initial input + this.updateInput({ ...initialInput }); + + // now that the input with the initial panel state has been set, we can tell the container class it's time to start loading children. + readyToInitializeChildren$.next(initialInput); + + // build Control Group + if (creationOptions?.useControlGroupIntegration) { + this.controlGroup = await startControlGroupIntegration.bind(this)(initialInput); + } + + // start diffing dashboard state + const diffingMiddleware = startDiffingDashboardState.bind(this)({ + initialInput, + initialLastSavedInput: inputFromSavedObject, + useSessionBackup: creationOptions?.useSessionStorageIntegration, + setCleanupFunction: (cleanup) => { + this.stopDiffingDashboardState = cleanup; + }, + }); + + // set up data views integration + this.dataViewsChangeSubscription = startSyncingDashboardDataViews.bind(this)(); + + // build redux embeddable tools + this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools< + DashboardReduxState, + typeof dashboardContainerReducers + >({ + embeddable: this as Embeddable, // cast to unwrapped state type + reducers: dashboardContainerReducers, + additionalMiddleware: [diffingMiddleware], + initialComponentState: { + lastSavedInput: inputFromSavedObject, + hasUnsavedChanges: false, // if there is initial unsaved changes, the initial diff will catch them. + lastSavedId: this.initialSavedDashboardId, + }, + }); + + this.initialized$.next(true); + } + + public async untilInitialized() { + if (this.initialized$.value) return Promise.resolve(); + return new Promise((resolve) => { + const subscription = this.initialized$.subscribe((isInitialized) => { + if (isInitialized) { + resolve(); + subscription.unsubscribe(); + } + }); + }); + } + + private onDataLoaded(data: DashboardLoadedInfo) { + if (this.analyticsService) { + reportPerformanceMetricEvent(this.analyticsService, { + eventName: DASHBOARD_LOADED_EVENT, + duration: data.timeToDone, + key1: 'time_to_data', + value1: data.timeToData, + key2: 'num_of_panels', + value2: data.numOfPanels, + }); + } + } + + protected createNewPanelState< + TEmbeddableInput extends EmbeddableInput, + TEmbeddable extends IEmbeddable + >( + factory: EmbeddableFactory, + partial: Partial = {} + ): DashboardPanelState { + const panelState = super.createNewPanelState(factory, partial); + const { newPanel } = createPanelState(panelState, this.input.panels); + return newPanel; + } + + public async getExplicitInputIsEqual(lastExplicitInput: DashboardContainerByValueInput) { + const currentInput = this.getReduxEmbeddableTools().getState().explicitInput; + return ( + omit( + Object.keys(await getUnsavedChanges.bind(this)(lastExplicitInput, currentInput)), + keysNotConsideredUnsavedChanges + ).length > 0 + ); + } + + public getReduxEmbeddableTools() { + if (!this.reduxEmbeddableTools) { + throw new Error('Dashboard must be initialized before accessing redux embeddable tools'); + } + return this.reduxEmbeddableTools; + } + + public render(dom: HTMLElement) { + if (!this.reduxEmbeddableTools) { + throw new Error('Dashboard must be initialized before it can be rendered'); + } + if (this.domNode) { + ReactDOM.unmountComponentAtNode(this.domNode); + } + this.domNode = dom; + + const { Wrapper: DashboardReduxWrapper } = this.reduxEmbeddableTools; + ReactDOM.render( + + + + + + + + + , + dom + ); + } + + protected getInheritedInput(id: string): InheritedChildInput { + const { + query, + filters, + viewMode, + timeRange, + timeslice, + syncColors, + syncTooltips, + hidePanelTitles, + searchSessionId, + refreshInterval, + executionContext, + } = this.input as DashboardContainerByValueInput; + + let combinedFilters = filters; + if (this.controlGroup) { + combinedFilters = combineDashboardFiltersWithControlGroupFilters(filters, this.controlGroup); + } + return { + refreshConfig: refreshInterval, + filters: combinedFilters, + hidePanelTitles, + searchSessionId, + executionContext, + syncTooltips, + syncColors, + timeRange, + timeslice, + viewMode, + query, + id, + }; + } + + // ------------------------------------------------------------------------------------------------------ + // Cleanup + // ------------------------------------------------------------------------------------------------------ + private stopDiffingDashboardState?: () => void; + private stopSyncingWithUnifiedSearch?: () => void; + private dataViewsChangeSubscription?: Subscription = undefined; + private stopSyncingDashboardSearchSessions: (() => void) | undefined; + + public destroy() { + super.destroy(); + this.onDestroyControlGroup?.(); + this.subscriptions.unsubscribe(); + this.stopDiffingDashboardState?.(); + this.reduxEmbeddableTools?.cleanup(); + this.stopSyncingWithUnifiedSearch?.(); + this.stopSyncingDashboardSearchSessions?.(); + this.dataViewsChangeSubscription?.unsubscribe(); + if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); + } + + // ------------------------------------------------------------------------------------------------------ + // Dashboard API + // ------------------------------------------------------------------------------------------------------ + + /** + * Sometimes when the ID changes, it's due to a clone operation, or a save as operation. In these cases, + * most of the state hasn't actually changed, so there isn't any reason to destroy this container and + * load up a fresh one. When an id change is in progress, the renderer can check this method, and if it returns + * true, the renderer can safely skip destroying and rebuilding the container. + */ + public isExpectingIdChange() { + return this.expectingIdChange; + } + private expectingIdChange = false; + public expectIdChange() { + /** + * this.expectingIdChange = true; TODO - re-enable this for saving speed-ups. It causes some functional test failures because the _g param is not carried over. + * See https://github.com/elastic/kibana/issues/147491 for more information. + **/ + setTimeout(() => { + this.expectingIdChange = false; + }, 1); // turn this off after the next update. + } + + public runClone = runClone; + public runSaveAs = runSaveAs; + public runQuickSave = runQuickSave; + + public showOptions = showOptions; + public addFromLibrary = addFromLibrary; + + public replacePanel = replacePanel; + public showPlaceholderUntil = showPlaceholderUntil; + public addOrUpdateEmbeddable = addOrUpdateEmbeddable; + + public forceRefresh() { + const { + dispatch, + actions: { setLastReloadRequestTimeToNow }, + } = this.getReduxEmbeddableTools(); + dispatch(setLastReloadRequestTimeToNow({})); + this.controlGroup?.reload(); + } + + public onDataViewsUpdate$ = new Subject(); + + public resetToLastSavedState() { + const { + dispatch, + getState, + actions: { resetToLastSavedInput }, + } = this.getReduxEmbeddableTools(); + dispatch(resetToLastSavedInput({})); + const { + explicitInput: { timeRange, refreshInterval }, + componentState: { + lastSavedInput: { timeRestore: lastSavedTimeRestore }, + }, + } = getState(); + + // if we are using the unified search integration, we need to force reset the time picker. + if (this.creationOptions?.useUnifiedSearchIntegration && lastSavedTimeRestore) { + const { + data: { + query: { + timefilter: { timefilter: timeFilterService }, + }, + }, + } = pluginServices.getServices(); + if (timeRange) timeFilterService.setTime(timeRange); + if (refreshInterval) timeFilterService.setRefreshInterval(refreshInterval); + } + } + + /** + * Gets all the dataviews that are actively being used in the dashboard + * @returns An array of dataviews + */ + public getAllDataViews = () => { + return this.allDataViews; + }; + + /** + * Use this to set the dataviews that are used in the dashboard when they change/update + * @param newDataViews The new array of dataviews that will overwrite the old dataviews array + */ + public setAllDataViews = (newDataViews: DataView[]) => { + this.allDataViews = newDataViews; + this.onDataViewsUpdate$.next(newDataViews); + }; + + public getExpandedPanelId = () => { + if (!this.reduxEmbeddableTools) throw new Error(); + return this.reduxEmbeddableTools.getState().componentState.expandedPanelId; + }; + + public openOverlay = (ref: OverlayRef) => { + this.clearOverlays(); + this.overlayRef = ref; + }; + + public clearOverlays = () => { + this.controlGroup?.closeAllFlyouts(); + this.overlayRef?.close(); + }; + + public setExpandedPanelId = (newId?: string) => { + if (!this.reduxEmbeddableTools) throw new Error(); + const { + actions: { setExpandedPanelId }, + dispatch, + } = this.reduxEmbeddableTools; + dispatch(setExpandedPanelId(newId)); + }; + + public getPanelCount = () => { + return Object.keys(this.getInput().panels).length; + }; + + public async getPanelTitles(): Promise { + const titles: string[] = []; + const ids: string[] = Object.keys(this.getInput().panels); + for (const panelId of ids) { + await this.untilEmbeddableLoaded(panelId); + const child: IEmbeddable = this.getChild(panelId); + const title = child.getTitle(); + if (title) { + titles.push(title); + } + } + return titles; + } +} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container_factory.tsx new file mode 100644 index 000000000000..703d9e2c4a34 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container_factory.tsx @@ -0,0 +1,107 @@ +/* + * 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 { Observable } from 'rxjs'; +import { i18n } from '@kbn/i18n'; + +import { + Container, + ErrorEmbeddable, + ContainerOutput, + EmbeddableFactory, + EmbeddableFactoryDefinition, + EmbeddablePackageState, +} from '@kbn/embeddable-plugin/public'; +import { SearchSessionInfoProvider } from '@kbn/data-plugin/public'; +import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; +import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; + +import { + createInject, + createExtract, + DashboardContainerInput, + DashboardContainerByValueInput, +} from '../../../common'; +import { DASHBOARD_CONTAINER_TYPE } from '..'; +import type { DashboardContainer } from './dashboard_container'; +import { DEFAULT_DASHBOARD_INPUT } from '../../dashboard_constants'; +import { LoadDashboardFromSavedObjectReturn } from '../../services/dashboard_saved_object/lib/load_dashboard_state_from_saved_object'; + +export type DashboardContainerFactory = EmbeddableFactory< + DashboardContainerInput, + ContainerOutput, + DashboardContainer +>; + +export interface DashboardCreationOptions { + initialInput?: Partial; + overrideInput?: Partial; + + incomingEmbeddable?: EmbeddablePackageState; + + useSearchSessionsIntegration?: boolean; + searchSessionSettings?: { + sessionIdToRestore?: string; + sessionIdUrlChangeObservable?: Observable; + getSearchSessionIdFromURL: () => string | undefined; + removeSessionIdFromUrl: () => void; + createSessionRestorationDataProvider: ( + container: DashboardContainer + ) => SearchSessionInfoProvider; + }; + + useControlGroupIntegration?: boolean; + useSessionStorageIntegration?: boolean; + + useUnifiedSearchIntegration?: boolean; + unifiedSearchSettings?: { kbnUrlStateStorage: IKbnUrlStateStorage }; + + validateLoadedSavedObject?: (result: LoadDashboardFromSavedObjectReturn) => boolean; +} + +export class DashboardContainerFactoryDefinition + implements + EmbeddableFactoryDefinition +{ + public readonly isContainerType = true; + public readonly type = DASHBOARD_CONTAINER_TYPE; + + public inject: EmbeddablePersistableStateService['inject']; + public extract: EmbeddablePersistableStateService['extract']; + + constructor(private readonly persistableStateService: EmbeddablePersistableStateService) { + this.inject = createInject(this.persistableStateService); + this.extract = createExtract(this.persistableStateService); + } + + public isEditable = async () => { + // Currently unused for dashboards + return false; + }; + + public readonly getDisplayName = () => { + return i18n.translate('dashboard.factory.displayName', { + defaultMessage: 'Dashboard', + }); + }; + + public getDefaultInput(): Partial { + return DEFAULT_DASHBOARD_INPUT; + } + + public create = async ( + initialInput: DashboardContainerInput, + parent?: Container, + creationOptions?: DashboardCreationOptions + ): Promise => { + const { DashboardContainer: DashboardContainerEmbeddable } = await import( + './dashboard_container' + ); + return Promise.resolve(new DashboardContainerEmbeddable(initialInput, parent, creationOptions)); + }; +} diff --git a/src/plugins/dashboard/public/application/lib/dashboard_control_group.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/controls/dashboard_control_group_integration.test.ts similarity index 98% rename from src/plugins/dashboard/public/application/lib/dashboard_control_group.test.ts rename to src/plugins/dashboard/public/dashboard_container/embeddable/integrations/controls/dashboard_control_group_integration.test.ts index 19cdf4ba2eff..ae8102318e36 100644 --- a/src/plugins/dashboard/public/application/lib/dashboard_control_group.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/controls/dashboard_control_group_integration.test.ts @@ -10,7 +10,7 @@ import { mockControlGroupInput } from '@kbn/controls-plugin/common/mocks'; import { ControlGroupContainer } from '@kbn/controls-plugin/public/control_group/embeddable/control_group_container'; import { Filter } from '@kbn/es-query'; import { ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; -import { combineDashboardFiltersWithControlGroupFilters } from './dashboard_control_group'; +import { combineDashboardFiltersWithControlGroupFilters } from './dashboard_control_group_integration'; jest.mock('@kbn/controls-plugin/public/control_group/embeddable/control_group_container'); diff --git a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/controls/dashboard_control_group_integration.ts similarity index 52% rename from src/plugins/dashboard/public/application/lib/dashboard_control_group.ts rename to src/plugins/dashboard/public/dashboard_container/embeddable/integrations/controls/dashboard_control_group_integration.ts index edca78a37313..4e78d9185f7c 100644 --- a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/controls/dashboard_control_group_integration.ts @@ -6,23 +6,24 @@ * Side Public License, v 1. */ -import _ from 'lodash'; -import { Subscription } from 'rxjs'; +import _, { identity, pickBy } from 'lodash'; +import { Observable, Subscription } from 'rxjs'; import deepEqual from 'fast-deep-equal'; import { compareFilters, COMPARE_ALL_OPTIONS, type Filter } from '@kbn/es-query'; import { debounceTime, distinctUntilChanged, distinctUntilKeyChanged, skip } from 'rxjs/operators'; import { ControlGroupInput, + CONTROL_GROUP_TYPE, getDefaultControlGroupInput, persistableControlGroupInputIsEqual, - controlGroupInputToRawControlGroupAttributes, } from '@kbn/controls-plugin/common'; -import { ControlGroupContainer } from '@kbn/controls-plugin/public'; +import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; +import { ControlGroupContainer, ControlGroupOutput } from '@kbn/controls-plugin/public'; -import { DashboardContainer } from '..'; -import { DashboardState } from '../../types'; -import { DashboardContainerInput } from '../..'; +import { DashboardContainer } from '../../dashboard_container'; +import { pluginServices } from '../../../../services/plugin_services'; +import { DashboardContainerByValueInput } from '../../../../../common'; interface DiffChecks { [key: string]: (a?: unknown, b?: unknown) => boolean; @@ -34,23 +35,54 @@ const distinctUntilDiffCheck = (a: T, b: T, diffChecks: DiffChecks .includes(false); type DashboardControlGroupCommonKeys = keyof Pick< - DashboardContainerInput | ControlGroupInput, + DashboardContainerByValueInput | ControlGroupInput, 'filters' | 'lastReloadRequestTime' | 'timeRange' | 'query' >; -export const syncDashboardControlGroup = async ({ - controlGroup, - dashboardContainer, -}: { - controlGroup: ControlGroupContainer; - dashboardContainer: DashboardContainer; -}) => { +export async function startControlGroupIntegration( + this: DashboardContainer, + initialInput: DashboardContainerByValueInput +): Promise { + const { + embeddable: { getEmbeddableFactory }, + } = pluginServices.getServices(); + const controlsGroupFactory = getEmbeddableFactory< + ControlGroupInput, + ControlGroupOutput, + ControlGroupContainer + >(CONTROL_GROUP_TYPE); + const { filters, query, timeRange, viewMode, controlGroupInput, id } = initialInput; + const controlGroup = await controlsGroupFactory?.create({ + id: `control_group_${id ?? 'new_dashboard'}`, + ...getDefaultControlGroupInput(), + ...pickBy(controlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults + timeRange, + viewMode, + filters, + query, + }); + if (!controlGroup || isErrorEmbeddable(controlGroup)) { + return; + } + + this.untilInitialized().then(() => startSyncingDashboardControlGroup.bind(this)()); + await controlGroup.untilInitialized(); + return controlGroup; +} + +async function startSyncingDashboardControlGroup(this: DashboardContainer) { + if (!this.controlGroup) return; const subscriptions = new Subscription(); + const { + actions: { setControlGroupState }, + dispatch, + } = this.getReduxEmbeddableTools(); + const isControlGroupInputEqual = () => persistableControlGroupInputIsEqual( - controlGroup.getInput(), - dashboardContainer.getInput().controlGroupInput + this.controlGroup!.getInput(), + this.getInputAsValueType().controlGroupInput ); // Because dashboard container stores control group state, certain control group changes need to be passed up dashboard container @@ -60,9 +92,8 @@ export const syncDashboardControlGroup = async ({ chainingSystem: deepEqual, ignoreParentSettings: deepEqual, }; - subscriptions.add( - controlGroup + this.controlGroup .getInput$() .pipe( distinctUntilChanged((a, b) => @@ -71,11 +102,11 @@ export const syncDashboardControlGroup = async ({ ) .subscribe(() => { const { panels, controlStyle, chainingSystem, ignoreParentSettings } = - controlGroup.getInput(); + this.controlGroup!.getInput(); if (!isControlGroupInputEqual()) { - dashboardContainer.updateInput({ - controlGroupInput: { panels, controlStyle, chainingSystem, ignoreParentSettings }, - }); + dispatch( + setControlGroupState({ panels, controlStyle, chainingSystem, ignoreParentSettings }) + ); } }) ); @@ -92,11 +123,10 @@ export const syncDashboardControlGroup = async ({ // pass down any pieces of input needed to refetch or force refetch data for the controls subscriptions.add( - dashboardContainer - .getInput$() + (this.getInput$() as Readonly>) .pipe( distinctUntilChanged((a, b) => - distinctUntilDiffCheck(a, b, dashboardRefetchDiff) + distinctUntilDiffCheck(a, b, dashboardRefetchDiff) ) ) .subscribe(() => { @@ -104,38 +134,39 @@ export const syncDashboardControlGroup = async ({ (Object.keys(dashboardRefetchDiff) as DashboardControlGroupCommonKeys[]).forEach((key) => { if ( !dashboardRefetchDiff[key]?.( - dashboardContainer.getInput()[key], - controlGroup.getInput()[key] + this.getInputAsValueType()[key], + this.controlGroup!.getInput()[key] ) ) { - newInput[key] = dashboardContainer.getInput()[key]; + newInput[key] = this.getInputAsValueType()[key]; } }); if (Object.keys(newInput).length > 0) { - controlGroup.updateInput(newInput); + this.controlGroup!.updateInput(newInput); } }) ); // dashboard may reset the control group input when discarding changes. Subscribe to these changes and update accordingly subscriptions.add( - dashboardContainer - .getInput$() + (this.getInput$() as Readonly>) .pipe(debounceTime(10), distinctUntilKeyChanged('controlGroupInput')) .subscribe(() => { if (!isControlGroupInputEqual()) { - if (!dashboardContainer.getInput().controlGroupInput) { - controlGroup.updateInput(getDefaultControlGroupInput()); + if (!this.getInputAsValueType().controlGroupInput) { + this.controlGroup!.updateInput(getDefaultControlGroupInput()); return; } - controlGroup.updateInput({ ...dashboardContainer.getInput().controlGroupInput }); + this.controlGroup!.updateInput({ + ...this.getInputAsValueType().controlGroupInput, + }); } }) ); // when control group outputs filters, force a refresh! subscriptions.add( - controlGroup + this.controlGroup .getOutput$() .pipe( distinctUntilChanged(({ filters: filtersA }, { filters: filtersB }) => @@ -143,11 +174,11 @@ export const syncDashboardControlGroup = async ({ ), skip(1) // skip first filter output because it will have been applied in initialize ) - .subscribe(() => dashboardContainer.updateInput({ lastReloadRequestTime: Date.now() })) + .subscribe(() => this.updateInput({ lastReloadRequestTime: Date.now() })) ); subscriptions.add( - controlGroup + this.controlGroup .getOutput$() .pipe( distinctUntilChanged(({ timeslice: timesliceA }, { timeslice: timesliceB }) => @@ -155,30 +186,34 @@ export const syncDashboardControlGroup = async ({ ) ) .subscribe(({ timeslice }) => { - dashboardContainer.updateInput({ timeslice }); + this.updateInput({ timeslice }); }) ); + // the Control Group needs to know when any dashboard children are loading in order to know when to move on to the next time slice when playing. + subscriptions.add( + this.getAnyChildOutputChange$().subscribe(() => { + if (!this.controlGroup) { + return; + } + + for (const child of Object.values(this.children)) { + const isLoading = child.getOutput().loading; + if (isLoading) { + this.controlGroup.anyControlOutputConsumerLoading$.next(true); + return; + } + } + this.controlGroup.anyControlOutputConsumerLoading$.next(false); + }) + ); + return { - onDestroyControlGroup: () => { + stopSyncingWithControlGroup: () => { subscriptions.unsubscribe(); - controlGroup.destroy(); }, }; -}; - -export const serializeControlGroupInput = ( - controlGroupInput: DashboardState['controlGroupInput'] -) => { - // only save to saved object if control group is not default - if ( - !controlGroupInput || - persistableControlGroupInputIsEqual(controlGroupInput, getDefaultControlGroupInput()) - ) { - return undefined; - } - return controlGroupInputToRawControlGroupAttributes(controlGroupInput); -}; +} export const combineDashboardFiltersWithControlGroupFilters = ( dashboardFilters: Filter[], diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_data_views.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/data_views/sync_dashboard_data_views.ts similarity index 76% rename from src/plugins/dashboard/public/application/lib/sync_dashboard_data_views.ts rename to src/plugins/dashboard/public/dashboard_container/embeddable/integrations/data_views/sync_dashboard_data_views.ts index afb59e050a20..d8365394b8a2 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_data_views.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/data_views/sync_dashboard_data_views.ts @@ -13,22 +13,28 @@ import { distinctUntilChanged, switchMap, filter, map } from 'rxjs/operators'; import { DataView } from '@kbn/data-views-plugin/common'; import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; -import { DashboardContainer } from '..'; -import { pluginServices } from '../../services/plugin_services'; +import { pluginServices } from '../../../../services/plugin_services'; +import { DashboardContainer } from '../../dashboard_container'; -interface SyncDashboardDataViewsProps { - dashboardContainer: DashboardContainer; - onUpdateDataViews: (newDataViewIds: string[]) => void; -} - -export const syncDashboardDataViews = ({ - dashboardContainer, - onUpdateDataViews, -}: SyncDashboardDataViewsProps) => { +export function startSyncingDashboardDataViews(this: DashboardContainer) { const { data: { dataViews }, } = pluginServices.getServices(); + const onUpdateDataViews = async (newDataViewIds: string[]) => { + if (this.controlGroup) this.controlGroup.setRelevantDataViewId(newDataViewIds[0]); + + // fetch all data views. These should be cached locally at this time so we will not need to query ES. + const responses = await Promise.allSettled(newDataViewIds.map((id) => dataViews.get(id))); + // Keep only fullfilled ones as each panel will handle the rejected ones already on their own + const allDataViews = responses + .filter( + (response): response is PromiseFulfilledResult => response.status === 'fulfilled' + ) + .map(({ value }) => value); + this.setAllDataViews(allDataViews); + }; + const updateDataViewsOperator = pipe( filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)), map((container: DashboardContainer): string[] | undefined => { @@ -91,14 +97,13 @@ export const syncDashboardDataViews = ({ }) ); - const dataViewSources = [dashboardContainer.getOutput$()]; - if (dashboardContainer.controlGroup) - dataViewSources.push(dashboardContainer.controlGroup.getOutput$()); + const dataViewSources = [this.getOutput$()]; + if (this.controlGroup) dataViewSources.push(this.controlGroup.getOutput$()); return combineLatest(dataViewSources) .pipe( - map(() => dashboardContainer), + map(() => this), updateDataViewsOperator ) .subscribe(); -}; +} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/diff_state/dashboard_diffing_functions.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/diff_state/dashboard_diffing_functions.ts new file mode 100644 index 000000000000..c96424e44b38 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/diff_state/dashboard_diffing_functions.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import fastIsEqual from 'fast-deep-equal'; + +import { persistableControlGroupInputIsEqual } from '@kbn/controls-plugin/common'; +import { compareFilters, COMPARE_ALL_OPTIONS, isFilterPinned } from '@kbn/es-query'; + +import { DashboardContainer } from '../../dashboard_container'; +import { DashboardContainerByValueInput } from '../../../../../common'; +import { areTimesEqual, getPanelLayoutsAreEqual } from './dashboard_diffing_utils'; + +export interface DiffFunctionProps { + currentValue: DashboardContainerByValueInput[Key]; + lastValue: DashboardContainerByValueInput[Key]; + + currentInput: DashboardContainerByValueInput; + lastInput: DashboardContainerByValueInput; + container: DashboardContainer; +} + +export type DashboardDiffFunctions = { + [key in keyof Partial]: ( + props: DiffFunctionProps + ) => boolean | Promise; +}; + +export const isKeyEqual = async ( + key: keyof DashboardContainerByValueInput, + diffFunctionProps: DiffFunctionProps +) => { + const propsAsNever = diffFunctionProps as never; // todo figure out why props has conflicting types in some constituents. + const diffingFunction = dashboardDiffingFunctions[key]; + if (diffingFunction) { + return diffingFunction?.prototype?.name === 'AsyncFunction' + ? await diffingFunction(propsAsNever) + : diffingFunction(propsAsNever); + } + return fastIsEqual(diffFunctionProps.currentValue, diffFunctionProps.lastValue); +}; + +/** + * A collection of functions which diff individual keys of dashboard state. If a key is missing from this list it is + * diffed by the default diffing function, fastIsEqual. + */ +export const dashboardDiffingFunctions: DashboardDiffFunctions = { + panels: async ({ currentValue, lastValue, container }) => { + if (!getPanelLayoutsAreEqual(currentValue, lastValue)) return false; + + const explicitInputComparePromises = Object.values(currentValue).map( + (panel) => + new Promise((resolve, reject) => { + const embeddableId = panel.explicitInput.id; + if (!embeddableId) reject(); + try { + container.untilEmbeddableLoaded(embeddableId).then((embeddable) => + embeddable + .getExplicitInputIsEqual(lastValue[embeddableId].explicitInput) + .then((isEqual) => { + if (isEqual) { + // rejecting the promise if the input is equal. + reject(); + } else { + // resolving false here means that the panel is unequal. The first promise to resolve this way will return false from this function. + resolve(false); + } + }) + ); + } catch (e) { + reject(); + } + }) + ); + + // If any promise resolves, return false. The catch here is only called if all promises reject which means all panels are equal. + return await Promise.any(explicitInputComparePromises).catch(() => true); + }, + + filters: ({ currentValue, lastValue }) => + compareFilters( + currentValue.filter((f) => !isFilterPinned(f)), + lastValue.filter((f) => !isFilterPinned(f)), + COMPARE_ALL_OPTIONS + ), + + timeRange: ({ currentValue, lastValue, currentInput }) => { + if (!currentInput.timeRestore) return true; // if time restore is set to false, time range doesn't count as a change. + if ( + !areTimesEqual(currentValue?.from, lastValue?.from) || + !areTimesEqual(currentValue?.to, lastValue?.to) + ) { + return false; + } + return true; + }, + + controlGroupInput: ({ currentValue, lastValue }) => + persistableControlGroupInputIsEqual(currentValue, lastValue), + + viewMode: () => false, // When compared view mode is always considered unequal so that it gets backed up. +}; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/diff_state/dashboard_diffing_integration.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/diff_state/dashboard_diffing_integration.ts new file mode 100644 index 000000000000..281d8f397bc2 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/diff_state/dashboard_diffing_integration.ts @@ -0,0 +1,195 @@ +/* + * 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 { omit } from 'lodash'; +import { AnyAction, Middleware } from '@reduxjs/toolkit'; +import { debounceTime, Observable, Subject, switchMap } from 'rxjs'; + +import { DashboardContainer } from '../../dashboard_container'; +import { pluginServices } from '../../../../services/plugin_services'; +import { DashboardContainerByValueInput } from '../../../../../common'; +import { CHANGE_CHECK_DEBOUNCE } from '../../../../dashboard_constants'; +import { isKeyEqual } from './dashboard_diffing_functions'; +import { dashboardContainerReducers } from '../../../state/dashboard_container_reducers'; + +/** + * An array of reducers which cannot cause unsaved changes. Unsaved changes only compares the explicit input + * and the last saved input, so we can safely ignore any output reducers, and most componentState reducers. + * This is only for performance reasons, because the diffing function itself can be quite heavy. + */ +const reducersToIgnore: Array = [ + 'setTimeslice', + 'setFullScreenMode', + 'setSearchSessionId', + 'setExpandedPanelId', + 'setHasUnsavedChanges', +]; + +/** + * Some keys will often have deviated from their last saved state, but should not persist over reloads + */ +const keysToOmitFromSessionStorage: Array = [ + 'lastReloadRequestTime', + 'executionContext', + 'searchSessionId', + 'timeslice', + + 'timeRange', // Current behaviour expects time range not to be backed up. Revisit this? + 'refreshInterval', +]; + +/** + * Some keys will often have deviated from their last saved state, but should be + * ignored when calculating whether or not this dashboard has unsaved changes. + */ +export const keysNotConsideredUnsavedChanges: Array = [ + 'lastReloadRequestTime', + 'executionContext', + 'searchSessionId', + 'timeslice', + 'viewMode', +]; + +/** + * Does an initial diff between @param initialInput and @param initialLastSavedInput, and created a middleware + * which listens to the redux store and checks for & publishes the unsaved changes on dispatches. + */ +export function startDiffingDashboardState( + this: DashboardContainer, + { + initialInput, + useSessionBackup, + setCleanupFunction, + initialLastSavedInput, + }: { + useSessionBackup?: boolean; + initialInput: DashboardContainerByValueInput; + initialLastSavedInput: DashboardContainerByValueInput; + setCleanupFunction: (cleanupFunction: () => void) => void; + } +) { + const { dashboardSessionStorage } = pluginServices.getServices(); + + const checkForUnsavedChanges = async ( + lastState: DashboardContainerByValueInput, + currentState: DashboardContainerByValueInput + ): Promise => { + const unsavedChanges = await getUnsavedChanges.bind(this)(lastState, currentState); + + if (useSessionBackup) { + dashboardSessionStorage.setState( + this.getDashboardSavedObjectId(), + omit(unsavedChanges, keysToOmitFromSessionStorage) + ); + } + + return Object.keys(omit(unsavedChanges, keysNotConsideredUnsavedChanges)).length > 0; // omit view mode because it is always backed up + }; + + const checkForUnsavedChangesSubject$ = new Subject(); + + // middleware starts the check for unsaved changes function if the action dispatched could cause them. + const diffingMiddleware: Middleware = (store) => (next) => (action) => { + const dispatchedActionName = action.type.split('/')?.[1]; + if ( + dispatchedActionName && + dispatchedActionName !== 'updateEmbeddableReduxOutput' && // ignore any generic output updates. + !reducersToIgnore.includes(dispatchedActionName) + ) { + checkForUnsavedChangesSubject$.next(null); + } + next(action); + }; + + // once the dashboard is initialized, start listening to the subject + this.untilInitialized().then(() => { + const { + getState, + dispatch, + actions: { setHasUnsavedChanges }, + } = this.getReduxEmbeddableTools(); + + const getHasUnsavedChangesSubscription = checkForUnsavedChangesSubject$ + .pipe( + debounceTime(CHANGE_CHECK_DEBOUNCE), + switchMap(() => { + return new Observable((observer) => { + const { + explicitInput: currentInput, + componentState: { lastSavedInput }, + } = this.getReduxEmbeddableTools().getState(); + checkForUnsavedChanges(lastSavedInput, currentInput).then((hasChanges) => { + if (observer.closed) return; + if (getState().componentState.hasUnsavedChanges !== hasChanges) { + dispatch(setHasUnsavedChanges(hasChanges)); + } + }); + }); + }) + ) + .subscribe(); + + setCleanupFunction(() => getHasUnsavedChangesSubscription.unsubscribe()); + }); + + // set initial unsaved changes + checkForUnsavedChanges(initialLastSavedInput, initialInput).then( + async (initialUnsavedChanges) => { + await this.untilInitialized(); + if (!initialUnsavedChanges) return; // early return because we know hasUnsavedChanges has been initialized to false + const { + dispatch, + actions: { setHasUnsavedChanges }, + } = this.getReduxEmbeddableTools(); + dispatch(setHasUnsavedChanges(initialUnsavedChanges)); + } + ); + + return diffingMiddleware; +} + +/** + * Does a shallow diff between @param lastExplicitInput and @param currentExplicitInput and + * @returns an object out of the keys which are different. + */ +export async function getUnsavedChanges( + this: DashboardContainer, + lastInput: DashboardContainerByValueInput, + input: DashboardContainerByValueInput, + keys?: Array +) { + const allKeys = + keys ?? + ([...new Set([...Object.keys(lastInput), ...Object.keys(input)])] as Array< + keyof DashboardContainerByValueInput + >); + const keyComparePromises = allKeys.map( + (key) => + new Promise<{ key: keyof DashboardContainerByValueInput; isEqual: boolean }>((resolve) => + isKeyEqual(key, { + container: this, + + currentValue: input[key], + currentInput: input, + + lastValue: lastInput[key], + lastInput, + }).then((isEqual) => resolve({ key, isEqual })) + ) + ); + const unsavedChanges = (await Promise.allSettled(keyComparePromises)).reduce( + (changes, current) => { + if (current.status === 'fulfilled') { + const { key, isEqual } = current.value; + if (!isEqual) (changes as { [key: string]: unknown })[key] = input[key]; + } + return changes; + }, + {} as Partial + ); + return unsavedChanges; +} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/diff_state/dashboard_diffing_utils.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/diff_state/dashboard_diffing_utils.ts new file mode 100644 index 000000000000..cc69acd8762c --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/diff_state/dashboard_diffing_utils.ts @@ -0,0 +1,79 @@ +/* + * 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, xor } from 'lodash'; +import moment, { Moment } from 'moment'; +import fastIsEqual from 'fast-deep-equal'; + +import { DashboardPanelMap } from '../../../../../common'; + +const convertTimeToUTCString = (time?: string | Moment): undefined | string => { + if (moment(time).isValid()) { + return moment(time).utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'); + } else { + // If it's not a valid moment date, then it should be a string representing a relative time + // like 'now' or 'now-15m'. + return time as string; + } +}; + +export const areTimesEqual = ( + timeA?: string | Moment | undefined, + timeB?: string | Moment | undefined +) => { + return convertTimeToUTCString(timeA) === convertTimeToUTCString(timeB); +}; + +export const defaultDiffFunction = (a: unknown, b: unknown) => fastIsEqual(a, b); + +/** + * Checks whether the panel maps have the same keys, and if they do, whether all of the other keys inside each panel + * are equal. Skips explicit input as that needs to be handled asynchronously. + */ +export const getPanelLayoutsAreEqual = ( + originalPanels: DashboardPanelMap, + newPanels: DashboardPanelMap +) => { + const originalEmbeddableIds = Object.keys(originalPanels); + const newEmbeddableIds = Object.keys(newPanels); + + const embeddableIdDiff = xor(originalEmbeddableIds, newEmbeddableIds); + if (embeddableIdDiff.length > 0) { + return false; + } + const commonPanelDiff = (originalObj: Partial, newObj: Partial) => { + const differences: Partial = {}; + const keys = [ + ...new Set([ + ...(Object.keys(originalObj) as Array), + ...(Object.keys(newObj) as Array), + ]), + ]; + for (const key of keys) { + if (key === undefined) continue; + if (!defaultDiffFunction(originalObj[key], newObj[key])) differences[key] = newObj[key]; + } + return differences; + }; + + for (const embeddableId of newEmbeddableIds) { + const { + explicitInput: originalExplicitInput, + panelRefName: panelRefA, + ...commonPanelDiffOriginal + } = originalPanels[embeddableId]; + const { + explicitInput: newExplicitInput, + panelRefName: panelRefB, + ...commonPanelDiffNew + } = newPanels[embeddableId]; + + if (!isEmpty(commonPanelDiff(commonPanelDiffOriginal, commonPanelDiffNew))) return false; + } + return true; +}; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/index.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/index.ts new file mode 100644 index 000000000000..f5399991cc8a --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/index.ts @@ -0,0 +1,26 @@ +/* + * 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 { + getUnsavedChanges, + startDiffingDashboardState, + keysNotConsideredUnsavedChanges, +} from './diff_state/dashboard_diffing_integration'; + +export { + startUnifiedSearchIntegration, + applySavedFiltersToUnifiedSearch, +} from './unified_search/start_dashboard_unified_search_integration'; + +export { + startControlGroupIntegration, + combineDashboardFiltersWithControlGroupFilters, +} from './controls/dashboard_control_group_integration'; + +export { startSyncingDashboardDataViews } from './data_views/sync_dashboard_data_views'; +export { startDashboardSearchSessionIntegration } from './search_sessions/start_dashboard_search_session_integration'; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/search_sessions/start_dashboard_search_session_integration.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/search_sessions/start_dashboard_search_session_integration.ts new file mode 100644 index 000000000000..d8e1aadf0ed4 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/search_sessions/start_dashboard_search_session_integration.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 { Subscription } from 'rxjs'; +import { debounceTime, pairwise, skip } from 'rxjs/operators'; +import { noSearchSessionStorageCapabilityMessage } from '@kbn/data-plugin/public'; + +import { DashboardContainer } from '../../dashboard_container'; +import { pluginServices } from '../../../../services/plugin_services'; +import { DashboardContainerByValueInput } from '../../../../../common'; +import { CHANGE_CHECK_DEBOUNCE } from '../../../../dashboard_constants'; +import { DashboardCreationOptions } from '../../dashboard_container_factory'; +import { getUnsavedChanges } from '../diff_state/dashboard_diffing_integration'; + +/** + * input keys that will cause a new session to be created. + */ +const refetchKeys: Array = [ + 'query', + 'filters', + 'timeRange', + 'timeslice', + 'timeRestore', + 'lastReloadRequestTime', + + // also refetch when chart settings change + 'syncColors', + 'syncCursor', + 'syncTooltips', +]; + +/** + * Enables dashboard search sessions. + */ +export function startDashboardSearchSessionIntegration( + this: DashboardContainer, + searchSessionSettings: DashboardCreationOptions['searchSessionSettings'], + incomingEmbeddable: DashboardCreationOptions['incomingEmbeddable'] +) { + if (!searchSessionSettings) { + throw new Error('Cannot start search sessions integration without settings'); + } + const { + createSessionRestorationDataProvider, + sessionIdUrlChangeObservable, + getSearchSessionIdFromURL, + removeSessionIdFromUrl, + sessionIdToRestore, + } = searchSessionSettings; + + const { + data: { + search: { session }, + }, + dashboardCapabilities: { storeSearchSession: canStoreSearchSession }, + } = pluginServices.getServices(); + + // if this incoming embeddable has a session, continue it. + if (incomingEmbeddable?.searchSessionId) { + session.continue(incomingEmbeddable.searchSessionId); + } + if (sessionIdToRestore) { + session.restore(sessionIdToRestore); + } + const existingSession = session.getSessionId(); + + const initialSearchSessionId = + sessionIdToRestore ?? + (existingSession && incomingEmbeddable ? existingSession : session.start()); + + session.enableStorage(createSessionRestorationDataProvider(this), { + isDisabled: () => + canStoreSearchSession + ? { disabled: false } + : { + disabled: true, + reasonText: noSearchSessionStorageCapabilityMessage, + }, + }); + + let searchSessionIdChangeSubscription: Subscription | undefined; + let checkForSessionChangeSubscription: Subscription | undefined; + this.untilInitialized().then(() => { + // force refresh when the session id in the URL changes. This will also fire off the "handle search session change" below. + searchSessionIdChangeSubscription = sessionIdUrlChangeObservable + ?.pipe(skip(1)) + .subscribe(() => this.forceRefresh()); + + // listen to and compare states to determine when to launch a new session. + this.getInput$() + .pipe(pairwise(), debounceTime(CHANGE_CHECK_DEBOUNCE)) + .subscribe(async (states) => { + const [previous, current] = states as DashboardContainerByValueInput[]; + const changes = await getUnsavedChanges.bind(this)(previous, current, refetchKeys); + const shouldRefetch = Object.keys(changes).length > 0; + if (!shouldRefetch) return; + + const { + getState, + dispatch, + actions: { setSearchSessionId }, + } = this.getReduxEmbeddableTools(); + const currentSearchSessionId = getState().explicitInput.searchSessionId; + + const updatedSearchSessionId: string | undefined = (() => { + // do not update session id if this is irrelevant state change to prevent excessive searches + if (!shouldRefetch) return; + + let searchSessionIdFromURL = getSearchSessionIdFromURL(); + if (searchSessionIdFromURL) { + if (session.isRestore() && session.isCurrentSession(searchSessionIdFromURL)) { + // we had previously been in a restored session but have now changed state so remove the session id from the URL. + removeSessionIdFromUrl(); + searchSessionIdFromURL = undefined; + } else { + session.restore(searchSessionIdFromURL); + } + } + return searchSessionIdFromURL ?? session.start(); + })(); + + if (updatedSearchSessionId && updatedSearchSessionId !== currentSearchSessionId) { + dispatch(setSearchSessionId(updatedSearchSessionId)); + } + }); + }); + + return { + initialSearchSessionId, + stopSyncingDashboardSearchSessions: () => { + checkForSessionChangeSubscription?.unsubscribe(); + searchSessionIdChangeSubscription?.unsubscribe(); + }, + }; +} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/unified_search/start_dashboard_unified_search_integration.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/unified_search/start_dashboard_unified_search_integration.ts new file mode 100644 index 000000000000..322caf7846b6 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/unified_search/start_dashboard_unified_search_integration.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 { cloneDeep } from 'lodash'; + +import { syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public'; +import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; + +import { DashboardContainer } from '../../dashboard_container'; +import { pluginServices } from '../../../../services/plugin_services'; +import { DashboardContainerByValueInput } from '../../../../../common'; +import { syncUnifiedSearchState } from './sync_dashboard_unified_search_state'; + +/** + * Applies initial state to the query service, and the saved dashboard search source + * Sets up syncing and subscriptions between the filter state from the Data plugin + * and the dashboard Redux store. + */ +export function startUnifiedSearchIntegration( + this: DashboardContainer, + { + initialInput, + setCleanupFunction, + kbnUrlStateStorage, + }: { + kbnUrlStateStorage: IKbnUrlStateStorage; + initialInput: DashboardContainerByValueInput; + setCleanupFunction: (cleanupFunction: () => void) => void; + } +) { + const { + data: { query: queryService }, + } = pluginServices.getServices(); + const { timefilter } = queryService; + const { timefilter: timefilterService } = timefilter; + + // apply initial dashboard saved filters, query, and time range to the query bar. + applySavedFiltersToUnifiedSearch.bind(this)(initialInput); + + // starts syncing `_g` portion of url with query services + const { stop: stopSyncingQueryServiceStateWithUrl } = syncGlobalQueryStateWithUrl( + queryService, + kbnUrlStateStorage + ); + + const initialTimeRange = initialInput.timeRestore ? undefined : timefilterService.getTime(); + this.untilInitialized().then(() => { + const stopSyncingUnifiedSearchState = syncUnifiedSearchState.bind(this)(kbnUrlStateStorage); + setCleanupFunction(() => { + stopSyncingQueryServiceStateWithUrl?.(); + stopSyncingUnifiedSearchState?.(); + }); + }); + return initialTimeRange; +} + +export function applySavedFiltersToUnifiedSearch( + this: DashboardContainer, + initialInput?: DashboardContainerByValueInput +) { + const { + data: { + query: { filterManager, queryString, timefilter }, + }, + } = pluginServices.getServices(); + const { timefilter: timefilterService } = timefilter; + + const input = initialInput + ? initialInput + : this.getReduxEmbeddableTools().getState().explicitInput; + const { filters, query, timeRestore, timeRange, refreshInterval } = input; + + // apply filters and query to the query service + filterManager.setAppFilters(cloneDeep(filters ?? [])); + queryString.setQuery(query ?? queryString.getDefaultQuery()); + + /** + * If a global time range is not set explicitly and the time range was saved with the dashboard, apply + * time range and refresh interval to the query service. + */ + if (timeRestore) { + if (timeRange) timefilterService.setTime(timeRange); + if (refreshInterval) timefilterService.setRefreshInterval(refreshInterval); + } +} diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/unified_search/sync_dashboard_unified_search_state.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/unified_search/sync_dashboard_unified_search_state.ts new file mode 100644 index 000000000000..71e6cb411804 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/unified_search/sync_dashboard_unified_search_state.ts @@ -0,0 +1,105 @@ +/* + * 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 { merge, Subject } from 'rxjs'; +import { distinctUntilChanged, finalize, switchMap, tap } from 'rxjs/operators'; + +import type { Filter, Query } from '@kbn/es-query'; +import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; +import { cleanFiltersForSerialize } from '@kbn/presentation-util-plugin/public'; +import { connectToQueryState, waitUntilNextSessionCompletes$ } from '@kbn/data-plugin/public'; + +import { DashboardContainer } from '../../dashboard_container'; +import { pluginServices } from '../../../../services/plugin_services'; + +/** + * Sets up syncing and subscriptions between the filter state from the Data plugin + * and the dashboard Redux store. + */ +export function syncUnifiedSearchState( + this: DashboardContainer, + kbnUrlStateStorage: IKbnUrlStateStorage +) { + const { + data: { query: queryService, search }, + } = pluginServices.getServices(); + const { queryString, timefilter } = queryService; + const { timefilter: timefilterService } = timefilter; + + const { + getState, + dispatch, + onStateChange, + actions: { setFiltersAndQuery, setTimeRange }, + } = this.getReduxEmbeddableTools(); + + // get Observable for when the dashboard's saved filters or query change. + const OnFiltersChange$ = new Subject<{ filters: Filter[]; query: Query }>(); + const unsubscribeFromSavedFilterChanges = onStateChange(() => { + const { + explicitInput: { filters, query }, + } = getState(); + OnFiltersChange$.next({ + filters: filters ?? [], + query: query ?? queryString.getDefaultQuery(), + }); + }); + + // starts syncing app filters between dashboard state and filterManager + const { + explicitInput: { filters, query }, + } = getState(); + const intermediateFilterState: { filters: Filter[]; query: Query } = { + query: query ?? queryString.getDefaultQuery(), + filters: filters ?? [], + }; + + const stopSyncingAppFilters = connectToQueryState( + queryService, + { + get: () => intermediateFilterState, + set: ({ filters: newFilters, query: newQuery }) => { + intermediateFilterState.filters = cleanFiltersForSerialize(newFilters); + intermediateFilterState.query = newQuery; + dispatch(setFiltersAndQuery(intermediateFilterState)); + }, + state$: OnFiltersChange$.pipe(distinctUntilChanged()), + }, + { + query: true, + filters: true, + } + ); + + const timeRefreshSubscription = merge( + timefilterService.getRefreshIntervalUpdate$(), + timefilterService.getTimeUpdate$() + ).subscribe(() => dispatch(setTimeRange(timefilterService.getTime()))); + + const autoRefreshSubscription = timefilterService + .getAutoRefreshFetch$() + .pipe( + tap(() => { + this.forceRefresh(); + }), + switchMap((done) => + // best way on a dashboard to estimate that panels are updated is to rely on search session service state + waitUntilNextSessionCompletes$(search.session).pipe(finalize(done)) + ) + ) + .subscribe(); + + const stopSyncingUnifiedSearchState = () => { + autoRefreshSubscription.unsubscribe(); + timeRefreshSubscription.unsubscribe(); + unsubscribeFromSavedFilterChanges(); + stopSyncingAppFilters(); + }; + + return stopSyncingUnifiedSearchState; +} diff --git a/src/plugins/dashboard/public/application/state/index.ts b/src/plugins/dashboard/public/dashboard_container/index.ts similarity index 57% rename from src/plugins/dashboard/public/application/state/index.ts rename to src/plugins/dashboard/public/dashboard_container/index.ts index d39487617316..e8db65da031c 100644 --- a/src/plugins/dashboard/public/application/state/index.ts +++ b/src/plugins/dashboard/public/dashboard_container/index.ts @@ -6,7 +6,10 @@ * Side Public License, v 1. */ -export * from './dashboard_state_slice'; +export const DASHBOARD_CONTAINER_TYPE = 'dashboard'; -export { dashboardStateStore } from './dashboard_state_store'; -export { useDashboardDispatch, useDashboardSelector } from './dashboard_state_hooks'; +export type { DashboardContainer } from './embeddable/dashboard_container'; +export { + type DashboardContainerFactory, + DashboardContainerFactoryDefinition, +} from './embeddable/dashboard_container_factory'; diff --git a/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts b/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.ts new file mode 100644 index 000000000000..9ebfdebc410a --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/state/dashboard_container_reducers.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 { PayloadAction } from '@reduxjs/toolkit'; +import { DashboardPublicState, DashboardReduxState, DashboardStateFromSaveModal } from '../types'; +import { DashboardContainerByValueInput } from '../../../common'; + +export const dashboardContainerReducers = { + // ------------------------------------------------------------------------------ + // Content Reducers + // ------------------------------------------------------------------------------ + setControlGroupState: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.controlGroupInput = action.payload; + }, + + setPanels: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.panels = action.payload; + }, + + // ------------------------------------------------------------------------------ + // Meta info Reducers + // ------------------------------------------------------------------------------ + setStateFromSaveModal: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.componentState.lastSavedId = action.payload.lastSavedId; + + state.explicitInput.tags = action.payload.tags; + state.explicitInput.title = action.payload.title; + state.explicitInput.timeRange = action.payload.timeRange; + state.explicitInput.description = action.payload.description; + state.explicitInput.timeRestore = action.payload.timeRestore; + state.explicitInput.refreshInterval = action.payload.refreshInterval; + }, + + setDescription: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.description = action.payload; + }, + + setViewMode: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.viewMode = action.payload; + }, + + setTags: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.tags = action.payload; + }, + + setTitle: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.title = action.payload; + }, + + setSearchSessionId: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.searchSessionId = action.payload; + }, + + // ------------------------------------------------------------------------------ + // Unsaved Changes Reducers + // ------------------------------------------------------------------------------ + setHasUnsavedChanges: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.componentState.hasUnsavedChanges = action.payload; + }, + + setLastSavedInput: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.componentState.lastSavedInput = action.payload; + }, + + resetToLastSavedInput: (state: DashboardReduxState) => { + state.explicitInput = state.componentState.lastSavedInput; + }, + + // ------------------------------------------------------------------------------ + // Options Reducers + // ------------------------------------------------------------------------------ + setUseMargins: (state: DashboardReduxState, action: PayloadAction) => { + state.explicitInput.useMargins = action.payload; + }, + + setSyncCursor: (state: DashboardReduxState, action: PayloadAction) => { + state.explicitInput.syncCursor = action.payload; + }, + + setSyncColors: (state: DashboardReduxState, action: PayloadAction) => { + state.explicitInput.syncColors = action.payload; + }, + + setSyncTooltips: (state: DashboardReduxState, action: PayloadAction) => { + state.explicitInput.syncTooltips = action.payload; + }, + + setHidePanelTitles: (state: DashboardReduxState, action: PayloadAction) => { + state.explicitInput.hidePanelTitles = action.payload; + }, + + // ------------------------------------------------------------------------------ + // Filtering Reducers + // ------------------------------------------------------------------------------ + setFiltersAndQuery: ( + state: DashboardReduxState, + action: PayloadAction> + ) => { + state.explicitInput.filters = action.payload.filters; + state.explicitInput.query = action.payload.query; + }, + + setLastReloadRequestTimeToNow: (state: DashboardReduxState) => { + state.explicitInput.lastReloadRequestTime = new Date().getTime(); + }, + + setFilters: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.filters = action.payload; + }, + + setQuery: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.query = action.payload; + }, + + setSavedQueryId: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.componentState.savedQueryId = action.payload; + }, + + setTimeRestore: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.timeRestore = action.payload; + }, + + setTimeRange: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.timeRange = action.payload; + }, + + setTimeslice: ( + state: DashboardReduxState, + action: PayloadAction + ) => { + state.explicitInput.timeslice = action.payload; + }, + + setExpandedPanelId: (state: DashboardReduxState, action: PayloadAction) => { + state.componentState.expandedPanelId = action.payload; + }, + + setFullScreenMode: (state: DashboardReduxState, action: PayloadAction) => { + state.componentState.fullScreenMode = action.payload; + }, +}; diff --git a/src/plugins/dashboard/public/dashboard_container/types.ts b/src/plugins/dashboard/public/dashboard_container/types.ts new file mode 100644 index 000000000000..744f67daf0d5 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_container/types.ts @@ -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 type { ContainerOutput } from '@kbn/embeddable-plugin/public'; +import type { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; +import type { DashboardContainerByValueInput } from '../../common/dashboard_container/types'; + +export type DashboardReduxState = ReduxEmbeddableState< + DashboardContainerByValueInput, + DashboardContainerOutput, + DashboardPublicState +>; + +export type DashboardStateFromSaveModal = Pick< + DashboardContainerByValueInput, + 'title' | 'description' | 'tags' | 'timeRestore' | 'timeRange' | 'refreshInterval' +> & + Pick; + +export interface DashboardPublicState { + lastSavedInput: DashboardContainerByValueInput; + isEmbeddedExternally?: boolean; + hasUnsavedChanges?: boolean; + expandedPanelId?: string; + fullScreenMode?: boolean; + savedQueryId?: string; + lastSavedId?: string; +} + +export interface DashboardContainerOutput extends ContainerOutput { + usedDataViewIds?: string[]; +} + +export type DashboardLoadedEventStatus = 'done' | 'error'; + +export interface DashboardLoadedEventMeta { + status: DashboardLoadedEventStatus; +} + +export interface DashboardSaveOptions { + newTitle: string; + newTags?: string[]; + newDescription: string; + newCopyOnSave: boolean; + newTimeRestore: boolean; + onTitleDuplicate: () => void; + isTitleDuplicateConfirmed: boolean; +} diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts deleted file mode 100644 index 73c1969ae199..000000000000 --- a/src/plugins/dashboard/public/dashboard_strings.ts +++ /dev/null @@ -1,529 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; - -/** - * @param title {string} the current title of the dashboard - * @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title. - * @returns {string} A title to display to the user based on the above parameters. - */ -export function getDashboardTitle(title: string, viewMode: ViewMode, isNew: boolean): string { - const isEditMode = viewMode === ViewMode.EDIT; - const dashboardTitle = isNew ? getNewDashboardTitle() : title; - return isEditMode - ? i18n.translate('dashboard.strings.dashboardEditTitle', { - defaultMessage: 'Editing {title}', - values: { title: dashboardTitle }, - }) - : dashboardTitle; -} - -export const unsavedChangesBadge = { - getUnsavedChangedBadgeText: () => - i18n.translate('dashboard.unsavedChangesBadge', { - defaultMessage: 'Unsaved changes', - }), -}; - -export const getMigratedToastText = () => - i18n.translate('dashboard.migratedChanges', { - defaultMessage: 'Some panels have been successfully updated to the latest version.', - }); - -/* - Plugin -*/ - -export const getDashboardBreadcrumb = () => - i18n.translate('dashboard.dashboardAppBreadcrumbsTitle', { - defaultMessage: 'Dashboard', - }); - -export const getDashboardPageTitle = () => - i18n.translate('dashboard.dashboardPageTitle', { - defaultMessage: 'Dashboards', - }); - -export const dashboardFeatureCatalog = { - getTitle: () => - i18n.translate('dashboard.featureCatalogue.dashboardTitle', { - defaultMessage: 'Dashboard', - }), - getSubtitle: () => - i18n.translate('dashboard.featureCatalogue.dashboardSubtitle', { - defaultMessage: 'Analyze data in dashboards.', - }), - getDescription: () => - i18n.translate('dashboard.featureCatalogue.dashboardDescription', { - defaultMessage: 'Display and share a collection of visualizations and saved searches.', - }), -}; - -/* - Actions -*/ -export const dashboardCopyToDashboardAction = { - getDisplayName: () => - i18n.translate('dashboard.panel.copyToDashboard.title', { - defaultMessage: 'Copy to dashboard', - }), - getCancelButtonName: () => - i18n.translate('dashboard.panel.copyToDashboard.cancel', { - defaultMessage: 'Cancel', - }), - getAcceptButtonName: () => - i18n.translate('dashboard.panel.copyToDashboard.goToDashboard', { - defaultMessage: 'Copy and go to dashboard', - }), - getNewDashboardOption: () => - i18n.translate('dashboard.panel.copyToDashboard.newDashboardOptionLabel', { - defaultMessage: 'New dashboard', - }), - getExistingDashboardOption: () => - i18n.translate('dashboard.panel.copyToDashboard.existingDashboardOptionLabel', { - defaultMessage: 'Existing dashboard', - }), - getDescription: () => - i18n.translate('dashboard.panel.copyToDashboard.description', { - defaultMessage: 'Choose the destination dashboard.', - }), -}; - -export const dashboardAddToLibraryAction = { - getDisplayName: () => - i18n.translate('dashboard.panel.AddToLibrary', { - defaultMessage: 'Save to library', - }), - getSuccessMessage: (panelTitle: string) => - i18n.translate('dashboard.panel.addToLibrary.successMessage', { - defaultMessage: `Panel {panelTitle} was added to the visualize library`, - values: { panelTitle }, - }), -}; - -export const dashboardClonePanelAction = { - getDisplayName: () => - i18n.translate('dashboard.panel.clonePanel', { - defaultMessage: 'Clone panel', - }), - getClonedTag: () => - i18n.translate('dashboard.panel.title.clonedTag', { - defaultMessage: 'copy', - }), - getSuccessMessage: () => - i18n.translate('dashboard.panel.clonedToast', { - defaultMessage: 'Cloned panel', - }), -}; - -export const dashboardExpandPanelAction = { - getMinimizeTitle: () => - i18n.translate('dashboard.actions.toggleExpandPanelMenuItem.expandedDisplayName', { - defaultMessage: 'Minimize', - }), - getMaximizeTitle: () => - i18n.translate('dashboard.actions.toggleExpandPanelMenuItem.notExpandedDisplayName', { - defaultMessage: 'Maximize panel', - }), -}; - -export const dashboardExportCsvAction = { - getDisplayName: () => - i18n.translate('dashboard.actions.DownloadCreateDrilldownAction.displayName', { - defaultMessage: 'Download as CSV', - }), - getUntitledFilename: () => - i18n.translate('dashboard.actions.downloadOptionsUnsavedFilename', { - defaultMessage: 'untitled', - }), -}; - -export const dashboardUnlinkFromLibraryAction = { - getDisplayName: () => - i18n.translate('dashboard.panel.unlinkFromLibrary', { - defaultMessage: 'Unlink from library', - }), - getSuccessMessage: (panelTitle: string) => - i18n.translate('dashboard.panel.unlinkFromLibrary.successMessage', { - defaultMessage: `Panel {panelTitle} is no longer connected to the visualize library`, - values: { panelTitle }, - }), -}; - -export const dashboardLibraryNotification = { - getDisplayName: () => - i18n.translate('dashboard.panel.LibraryNotification', { - defaultMessage: 'Visualize Library Notification', - }), - getTooltip: () => - i18n.translate('dashboard.panel.libraryNotification.toolTip', { - defaultMessage: - 'Editing this panel might affect other dashboards. To change this panel only, unlink it from the library.', - }), - getPopoverAriaLabel: () => - i18n.translate('dashboard.panel.libraryNotification.ariaLabel', { - defaultMessage: 'View library information and unlink this panel', - }), -}; - -export const dashboardReplacePanelAction = { - getDisplayName: () => - i18n.translate('dashboard.panel.removePanel.replacePanel', { - defaultMessage: 'Replace panel', - }), - getSuccessMessage: (savedObjectName: string) => - i18n.translate('dashboard.addPanel.savedObjectAddedToContainerSuccessMessageTitle', { - defaultMessage: '{savedObjectName} was added', - values: { - savedObjectName, - }, - }), - getNoMatchingObjectsMessage: () => - i18n.translate('dashboard.addPanel.noMatchingObjectsMessage', { - defaultMessage: 'No matching objects found.', - }), -}; - -export const dashboardFilterNotificationAction = { - getDisplayName: () => - i18n.translate('dashboard.panel.filters', { - defaultMessage: 'Panel filters', - }), - getEditButtonTitle: () => - i18n.translate('dashboard.panel.filters.modal.editButton', { - defaultMessage: 'Edit filters', - }), - getCloseButtonTitle: () => - i18n.translate('dashboard.panel.filters.modal.closeButton', { - defaultMessage: 'Close', - }), - getQueryTitle: () => - i18n.translate('dashboard.panel.filters.modal.queryTitle', { - defaultMessage: 'Query', - }), - getFiltersTitle: () => - i18n.translate('dashboard.panel.filters.modal.filtersTitle', { - defaultMessage: 'Filters', - }), -}; - -/* - Dashboard Editor -*/ -export const getNewDashboardTitle = () => - i18n.translate('dashboard.savedDashboard.newDashboardTitle', { - defaultMessage: 'New Dashboard', - }); - -export const getDashboard60Warning = () => - i18n.translate('dashboard.urlWasRemovedInSixZeroWarningMessage', { - defaultMessage: 'The url "dashboard/create" was removed in 6.0. Please update your bookmarks.', - }); - -export const dashboardReadonlyBadge = { - getText: () => - i18n.translate('dashboard.badge.readOnly.text', { - defaultMessage: 'Read only', - }), - getTooltip: () => - i18n.translate('dashboard.badge.readOnly.tooltip', { - defaultMessage: 'Unable to save dashboards', - }), -}; - -export const dashboardSaveToastStrings = { - getSuccessString: (dashTitle: string) => - i18n.translate('dashboard.dashboardWasSavedSuccessMessage', { - defaultMessage: `Dashboard '{dashTitle}' was saved`, - values: { dashTitle }, - }), - getFailureString: (dashTitle: string, errorMessage: string) => - i18n.translate('dashboard.dashboardWasNotSavedDangerMessage', { - defaultMessage: `Dashboard '{dashTitle}' was not saved. Error: {errorMessage}`, - values: { - dashTitle, - errorMessage, - }, - }), -}; - -/* - Modals -*/ -export const shareModalStrings = { - getTopMenuCheckbox: () => - i18n.translate('dashboard.embedUrlParamExtension.topMenu', { - defaultMessage: 'Top menu', - }), - getQueryCheckbox: () => - i18n.translate('dashboard.embedUrlParamExtension.query', { - defaultMessage: 'Query', - }), - getTimeFilterCheckbox: () => - i18n.translate('dashboard.embedUrlParamExtension.timeFilter', { - defaultMessage: 'Time filter', - }), - getFilterBarCheckbox: () => - i18n.translate('dashboard.embedUrlParamExtension.filterBar', { - defaultMessage: 'Filter bar', - }), - getCheckboxLegend: () => - i18n.translate('dashboard.embedUrlParamExtension.include', { - defaultMessage: 'Include', - }), - getSnapshotShareWarning: () => - i18n.translate('dashboard.snapshotShare.longUrlWarning', { - defaultMessage: - 'One or more panels on this dashboard have changed. Before you generate a snapshot, save the dashboard.', - }), -}; - -export const leaveConfirmStrings = { - getLeaveTitle: () => - i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesTitle', { - defaultMessage: 'Unsaved changes', - }), - getLeaveSubtitle: () => - i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesSubtitle', { - defaultMessage: 'Leave Dashboard with unsaved work?', - }), - getLeaveCancelButtonText: () => - i18n.translate('dashboard.appLeaveConfirmModal.cancelButtonLabel', { - defaultMessage: 'Cancel', - }), -}; - -export const leaveEditModeConfirmStrings = { - getLeaveEditModeTitle: () => - i18n.translate('dashboard.changeViewModeConfirmModal.leaveEditModeTitle', { - defaultMessage: 'You have unsaved changes', - }), - getLeaveEditModeSubtitle: () => - i18n.translate('dashboard.changeViewModeConfirmModal.description', { - defaultMessage: `You can keep or discard your changes on return to view mode. You can't recover discarded changes.`, - }), - getLeaveEditModeKeepChangesText: () => - i18n.translate('dashboard.changeViewModeConfirmModal.keepUnsavedChangesButtonLabel', { - defaultMessage: 'Keep changes', - }), - getLeaveEditModeDiscardButtonText: () => - i18n.translate('dashboard.changeViewModeConfirmModal.confirmButtonLabel', { - defaultMessage: 'Discard changes', - }), - getLeaveEditModeCancelButtonText: () => - i18n.translate('dashboard.changeViewModeConfirmModal.cancelButtonLabel', { - defaultMessage: 'Continue editing', - }), -}; - -export const discardConfirmStrings = { - getDiscardTitle: () => - i18n.translate('dashboard.discardChangesConfirmModal.discardChangesTitle', { - defaultMessage: 'Discard changes to dashboard?', - }), - getDiscardSubtitle: () => - i18n.translate('dashboard.discardChangesConfirmModal.discardChangesDescription', { - defaultMessage: `Once you discard your changes, there's no getting them back.`, - }), - getDiscardConfirmButtonText: () => - i18n.translate('dashboard.discardChangesConfirmModal.confirmButtonLabel', { - defaultMessage: 'Discard changes', - }), - getDiscardCancelButtonText: () => - i18n.translate('dashboard.discardChangesConfirmModal.cancelButtonLabel', { - defaultMessage: 'Cancel', - }), -}; - -export const createConfirmStrings = { - getCreateTitle: () => - i18n.translate('dashboard.createConfirmModal.unsavedChangesTitle', { - defaultMessage: 'New dashboard already in progress', - }), - getCreateSubtitle: () => - i18n.translate('dashboard.createConfirmModal.unsavedChangesSubtitle', { - defaultMessage: 'Continue editing or start over with a blank dashboard.', - }), - getStartOverButtonText: () => - i18n.translate('dashboard.createConfirmModal.confirmButtonLabel', { - defaultMessage: 'Start over', - }), - getContinueButtonText: () => - i18n.translate('dashboard.createConfirmModal.continueButtonLabel', { - defaultMessage: 'Continue editing', - }), - getCancelButtonText: () => - i18n.translate('dashboard.createConfirmModal.cancelButtonLabel', { - defaultMessage: 'Cancel', - }), -}; - -/* - Error Messages -*/ - -export const panelStorageErrorStrings = { - getPanelsGetError: (message: string) => - i18n.translate('dashboard.panelStorageError.getError', { - defaultMessage: 'Error encountered while fetching unsaved changes: {message}', - values: { message }, - }), - getPanelsSetError: (message: string) => - i18n.translate('dashboard.panelStorageError.setError', { - defaultMessage: 'Error encountered while setting unsaved changes: {message}', - values: { message }, - }), - getPanelsClearError: (message: string) => - i18n.translate('dashboard.panelStorageError.clearError', { - defaultMessage: 'Error encountered while clearing unsaved changes: {message}', - values: { message }, - }), -}; - -export const dashboardSavedObjectErrorStrings = { - getDashboardLoadError: (message: string) => - i18n.translate('dashboard.loadingError.errorMessage', { - defaultMessage: 'Error encountered while loading saved dashboard: {message}', - values: { message }, - }), - getDashboardGridError: (message: string) => - i18n.translate('dashboard.loadingError.dashboardGridErrorMessage', { - defaultMessage: 'Unable to load dashboard: {message}', - values: { message }, - }), - getErrorDeletingDashboardToast: () => - i18n.translate('dashboard.deleteError.toastDescription', { - defaultMessage: 'Error encountered while deleting dashboard', - }), - getPanelTooOldError: () => - i18n.translate('dashboard.loadURLError.PanelTooOld', { - defaultMessage: 'Cannot load panels from a URL created in a version older than 7.3', - }), -}; - -/* - Empty Screen -*/ -export const emptyScreenStrings = { - getEmptyDashboardTitle: () => - i18n.translate('dashboard.emptyDashboardTitle', { - defaultMessage: 'This dashboard is empty.', - }), - getEmptyDashboardAdditionalPrivilege: () => - i18n.translate('dashboard.emptyDashboardAdditionalPrivilege', { - defaultMessage: 'You need additional privileges to edit this dashboard.', - }), - getFillDashboardTitle: () => - i18n.translate('dashboard.fillDashboardTitle', { - defaultMessage: 'This dashboard is empty. Let\u2019s fill it up!', - }), - getHowToStartWorkingOnNewDashboardDescription: () => - i18n.translate('dashboard.howToStartWorkingOnNewDashboardDescription', { - defaultMessage: 'Click edit in the menu bar above to start adding panels.', - }), - getHowToStartWorkingOnNewDashboardEditLinkAriaLabel: () => - i18n.translate('dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel', { - defaultMessage: 'Edit dashboard', - }), - getEmptyWidgetTitle: () => - i18n.translate('dashboard.emptyWidget.addPanelTitle', { - defaultMessage: 'Add your first visualization', - }), - getEmptyWidgetDescription: () => - i18n.translate('dashboard.emptyWidget.addPanelDescription', { - defaultMessage: 'Create content that tells a story about your data.', - }), -}; - -/* - Dashboard Listing Page -*/ -export const dashboardListingTableStrings = { - getEntityName: () => - i18n.translate('dashboard.listing.table.entityName', { - defaultMessage: 'dashboard', - }), - getEntityNamePlural: () => - i18n.translate('dashboard.listing.table.entityNamePlural', { - defaultMessage: 'dashboards', - }), - getTableListTitle: () => getDashboardPageTitle(), -}; - -export const dashboardUnsavedListingStrings = { - getUnsavedChangesTitle: (plural = false) => - i18n.translate('dashboard.listing.unsaved.unsavedChangesTitle', { - defaultMessage: 'You have unsaved changes in the following {dash}:', - values: { - dash: plural - ? dashboardListingTableStrings.getEntityNamePlural() - : dashboardListingTableStrings.getEntityName(), - }, - }), - getLoadingTitle: () => - i18n.translate('dashboard.listing.unsaved.loading', { - defaultMessage: 'Loading', - }), - getEditAriaLabel: (title: string) => - i18n.translate('dashboard.listing.unsaved.editAria', { - defaultMessage: 'Continue editing {title}', - values: { title }, - }), - getEditTitle: () => - i18n.translate('dashboard.listing.unsaved.editTitle', { - defaultMessage: 'Continue editing', - }), - getDiscardAriaLabel: (title: string) => - i18n.translate('dashboard.listing.unsaved.discardAria', { - defaultMessage: 'Discard changes to {title}', - values: { title }, - }), - getDiscardTitle: () => - i18n.translate('dashboard.listing.unsaved.discardTitle', { - defaultMessage: 'Discard changes', - }), -}; - -export const getCreateVisualizationButtonTitle = () => - i18n.translate('dashboard.solutionToolbar.addPanelButtonLabel', { - defaultMessage: 'Create visualization', - }); - -export const noItemsStrings = { - getReadonlyTitle: () => - i18n.translate('dashboard.listing.readonlyNoItemsTitle', { - defaultMessage: 'No dashboards to view', - }), - getReadonlyBody: () => - i18n.translate('dashboard.listing.readonlyNoItemsBody', { - defaultMessage: `There are no available dashboards. To change your permissions to view the dashboards in this space, contact your administrator.`, - }), - getReadEditTitle: () => - i18n.translate('dashboard.listing.createNewDashboard.title', { - defaultMessage: 'Create your first dashboard', - }), - getReadEditInProgressTitle: () => - i18n.translate('dashboard.listing.createNewDashboard.inProgressTitle', { - defaultMessage: 'Dashboard in progress', - }), - getReadEditDashboardDescription: () => - i18n.translate('dashboard.listing.createNewDashboard.combineDataViewFromKibanaAppDescription', { - defaultMessage: - 'Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.', - }), - getSampleDataLinkText: () => - i18n.translate('dashboard.listing.createNewDashboard.sampleDataInstallLinkText', { - defaultMessage: `Add some sample data`, - }), - getCreateNewDashboardText: () => - i18n.translate('dashboard.listing.createNewDashboard.createButtonLabel', { - defaultMessage: `Create a dashboard`, - }), -}; diff --git a/src/plugins/dashboard/public/events.ts b/src/plugins/dashboard/public/events.ts deleted file mode 100644 index aa28cb109abe..000000000000 --- a/src/plugins/dashboard/public/events.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export const DASHBOARD_LOADED_EVENT = 'dashboard_loaded'; diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 598940bbd666..83616f0ffceb 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -9,17 +9,19 @@ import { PluginInitializerContext } from '@kbn/core/public'; import { DashboardPlugin } from './plugin'; -export { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants'; -export { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; - +export { + createDashboardEditUrl, + DASHBOARD_APP_ID, + LEGACY_DASHBOARD_APP_ID, +} from './dashboard_constants'; +export { DASHBOARD_CONTAINER_TYPE } from './dashboard_container'; export type { DashboardSetup, DashboardStart, DashboardFeatureFlagConfig } from './plugin'; + export { type DashboardAppLocator, type DashboardAppLocatorParams, cleanEmptyKeys, -} from './locator'; - -export type { SavedDashboardPanel, DashboardContainerInput } from './types'; +} from './dashboard_app/locator/locator'; 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 ba582cd24544..c6668630548a 100644 --- a/src/plugins/dashboard/public/mocks.tsx +++ b/src/plugins/dashboard/public/mocks.tsx @@ -6,7 +6,14 @@ * Side Public License, v 1. */ +import { Embeddable, EmbeddableInput, ViewMode } from '@kbn/embeddable-plugin/public'; +import { createReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public/redux_embeddables/create_redux_embeddable_tools'; + import { DashboardStart } from './plugin'; +import { DashboardContainerByValueInput, DashboardPanelState } from '../common'; +import { DashboardContainerOutput, DashboardReduxState } from './dashboard_container/types'; +import { DashboardContainer } from './dashboard_container/embeddable/dashboard_container'; +import { dashboardContainerReducers } from './dashboard_container/state/dashboard_container_reducers'; export type Start = jest.Mocked; @@ -20,3 +27,111 @@ const createStartContract = (): DashboardStart => { export const dashboardPluginMock = { createStartContract, }; + +/** + * Utility function that mocks the `IntersectionObserver` API. Necessary for components that rely + * on it, otherwise the tests will crash. Recommended to execute inside `beforeEach`. + * + * @param intersectionObserverMock - Parameter that is sent to the `Object.defineProperty` + * overwrite method. `jest.fn()` mock functions can be passed here if the goal is to not only + * mock the intersection observer, but its methods. + */ +export function setupIntersectionObserverMock({ + root = null, + rootMargin = '', + thresholds = [], + disconnect = () => null, + observe = () => null, + takeRecords = () => [], + unobserve = () => null, +} = {}): void { + class MockIntersectionObserver implements IntersectionObserver { + readonly root: Element | null = root; + readonly rootMargin: string = rootMargin; + readonly thresholds: readonly number[] = thresholds; + disconnect: () => void = disconnect; + observe: (target: Element) => void = observe; + takeRecords: () => IntersectionObserverEntry[] = takeRecords; + unobserve: (target: Element) => void = unobserve; + } + + Object.defineProperty(window, 'IntersectionObserver', { + writable: true, + configurable: true, + value: MockIntersectionObserver, + }); + + Object.defineProperty(global, 'IntersectionObserver', { + writable: true, + configurable: true, + value: MockIntersectionObserver, + }); +} + +export const mockDashboardReduxEmbeddableTools = async ( + partialState?: Partial +) => { + const mockDashboard = new DashboardContainer( + getSampleDashboardInput(partialState?.explicitInput) + ) as Embeddable; + + const mockReduxEmbeddableTools = createReduxEmbeddableTools({ + embeddable: mockDashboard, + reducers: dashboardContainerReducers, + initialComponentState: { lastSavedInput: mockDashboard.getInput() }, + }); + + return { + tools: mockReduxEmbeddableTools, + dashboardContainer: mockDashboard as DashboardContainer, + }; +}; + +export function getSampleDashboardInput( + overrides?: Partial +): DashboardContainerByValueInput { + return { + // options + useMargins: true, + syncColors: false, + syncCursor: true, + syncTooltips: false, + hidePanelTitles: false, + + id: '123', + tags: [], + filters: [], + isEmbeddedExternally: false, + title: 'My Dashboard', + query: { + language: 'kuery', + query: 'hi', + }, + timeRange: { + to: 'now', + from: 'now-15m', + }, + timeRestore: false, + viewMode: ViewMode.VIEW, + panels: {}, + ...overrides, + }; +} + +export function getSampleDashboardPanel( + overrides: Partial> & { + explicitInput: { id: string }; + type: string; + } +): DashboardPanelState { + return { + gridData: { + h: 15, + w: 15, + x: 0, + y: 0, + i: overrides.explicitInput.id, + }, + ...overrides, + }; +} diff --git a/src/plugins/dashboard/public/application/embeddable/placeholder/index.ts b/src/plugins/dashboard/public/placeholder_embeddable/index.ts similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/placeholder/index.ts rename to src/plugins/dashboard/public/placeholder_embeddable/index.ts diff --git a/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable.tsx b/src/plugins/dashboard/public/placeholder_embeddable/placeholder_embeddable.tsx similarity index 95% rename from src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable.tsx rename to src/plugins/dashboard/public/placeholder_embeddable/placeholder_embeddable.tsx index de468d86c89f..33021433c01c 100644 --- a/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable.tsx +++ b/src/plugins/dashboard/public/placeholder_embeddable/placeholder_embeddable.tsx @@ -15,7 +15,7 @@ import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { Embeddable, type EmbeddableInput, type IContainer } from '@kbn/embeddable-plugin/public'; import { PLACEHOLDER_EMBEDDABLE } from '.'; -import { pluginServices } from '../../../services/plugin_services'; +import { pluginServices } from '../services/plugin_services'; export class PlaceholderEmbeddable extends Embeddable { public readonly type = PLACEHOLDER_EMBEDDABLE; diff --git a/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable_factory.ts b/src/plugins/dashboard/public/placeholder_embeddable/placeholder_embeddable_factory.ts similarity index 100% rename from src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable_factory.ts rename to src/plugins/dashboard/public/placeholder_embeddable/placeholder_embeddable_factory.ts diff --git a/src/plugins/dashboard/public/placeholder_embeddable/readme.md b/src/plugins/dashboard/public/placeholder_embeddable/readme.md new file mode 100644 index 000000000000..5bdb0569c50f --- /dev/null +++ b/src/plugins/dashboard/public/placeholder_embeddable/readme.md @@ -0,0 +1,13 @@ +## What is this for? + +This Placeholder Embeddable is shown when a BY REFERENCE panel (a panel which is linked to a saved object) is cloned using the Dashboard Panel Clone action. + +## Why was it made? + +This was important for the first iteration of the clone feature so that something could be shown while the saved object was being duplicated, but later iterations of that feature automatically unlink panels on clone. By Value panels don't need a placeholder because they load much faster. + +## Can I delete it? + +Currently, the only embeddable type that cannot be loaded by value is the Discover Saved Search Embeddable. Without a placeholder embeddable, the dashboard wouldn't reflow at all until after the saved object clone operation is complete. + +The placeholder embeddable should be removed as soon as the Discover Saved Search Embeddable can be saved By Value. diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index a0d35e2c7be4..00901b0849bb 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import { BehaviorSubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; @@ -30,11 +31,10 @@ import type { UsageCollectionStart, } from '@kbn/usage-collection-plugin/public'; import { APP_WRAPPER_CLASS } from '@kbn/core/public'; -import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/public'; -import { createKbnUrlTracker } from '@kbn/kibana-utils-plugin/public'; - import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; +import { createKbnUrlTracker } from '@kbn/kibana-utils-plugin/public'; +import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/public'; import type { SavedObjectsStart } from '@kbn/saved-objects-plugin/public'; import type { VisualizationsStart } from '@kbn/visualizations-plugin/public'; import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; @@ -49,16 +49,19 @@ import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plu import type { UrlForwardingSetup, UrlForwardingStart } from '@kbn/url-forwarding-plugin/public'; import type { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss-plugin/public'; +import { DashboardContainerFactoryDefinition } from './dashboard_container/embeddable/dashboard_container_factory'; +import { + type DashboardAppLocator, + DashboardAppLocatorDefinition, +} from './dashboard_app/locator/locator'; import { - type DashboardContainerFactory, - DashboardContainerFactoryDefinition, - createDashboardContainerByValueRenderer, -} from './application/embeddable'; -import type { DashboardMountContextProps } from './types'; -import { dashboardFeatureCatalog } from './dashboard_strings'; -import { type DashboardAppLocator, DashboardAppLocatorDefinition } from './locator'; -import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder'; -import { DashboardConstants, DASHBOARD_CONTAINER_TYPE } from './dashboard_constants'; + DASHBOARD_APP_ID, + LANDING_PAGE_PATH, + LEGACY_DASHBOARD_APP_ID, + SEARCH_SESSION_ID, +} from './dashboard_constants'; +import { PlaceholderEmbeddableFactory } from './placeholder_embeddable'; +import { DashboardMountContextProps } from './dashboard_app/types'; export interface DashboardFeatureFlagConfig { allowByValueEmbeddables: boolean; @@ -101,9 +104,6 @@ export interface DashboardSetup { } export interface DashboardStart { - getDashboardContainerByValueRenderer: () => ReturnType< - typeof createDashboardContainerByValueRenderer - >; locator?: DashboardAppLocator; dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; } @@ -146,7 +146,7 @@ export class DashboardPlugin dashboardSavedObject: { loadDashboardStateFromSavedObject }, } = pluginServices.getServices(); return ( - (await loadDashboardStateFromSavedObject({ id: dashboardId })).dashboardState + (await loadDashboardStateFromSavedObject({ id: dashboardId })).dashboardInput ?.filters ?? [] ); }, @@ -161,7 +161,7 @@ export class DashboardPlugin restorePreviousUrl, } = createKbnUrlTracker({ baseUrl: core.http.basePath.prepend('/app/dashboards'), - defaultSubUrl: `#${DashboardConstants.LANDING_PAGE_PATH}`, + defaultSubUrl: `#${LANDING_PAGE_PATH}`, storageKey: `lastUrl:${core.http.basePath.get()}:dashboard`, navLinkUpdater$: this.appStateUpdater, toastNotifications: core.notifications.toasts, @@ -187,9 +187,9 @@ export class DashboardPlugin // Do not save SEARCH_SESSION_ID into nav link, because of possible edge cases // that could lead to session restoration failure. // see: https://github.com/elastic/kibana/issues/87149 - if (newNavLink.includes(DashboardConstants.SEARCH_SESSION_ID)) { + if (newNavLink.includes(SEARCH_SESSION_ID)) { newNavLink = replaceUrlHashQuery(newNavLink, (query) => { - delete query[DashboardConstants.SEARCH_SESSION_ID]; + delete query[SEARCH_SESSION_ID]; return query; }); } @@ -214,17 +214,17 @@ export class DashboardPlugin }; const app: App = { - id: DashboardConstants.DASHBOARDS_ID, + id: DASHBOARD_APP_ID, title: 'Dashboard', order: 2500, euiIconType: 'logoKibana', - defaultPath: `#${DashboardConstants.LANDING_PAGE_PATH}`, + defaultPath: `#${LANDING_PAGE_PATH}`, updater$: this.appStateUpdater, category: DEFAULT_APP_CATEGORIES.kibana, mount: async (params: AppMountParameters) => { this.currentHistory = params.history; params.element.classList.add(APP_WRAPPER_CLASS); - const { mountApp } = await import('./application/dashboard_router'); + const { mountApp } = await import('./dashboard_app/dashboard_router'); appMounted(); const mountContext: DashboardMountContextProps = { @@ -244,41 +244,39 @@ export class DashboardPlugin }; core.application.register(app); - urlForwarding.forwardApp( - DashboardConstants.DASHBOARDS_ID, - DashboardConstants.DASHBOARDS_ID, - (path) => { - const [, tail] = /(\?.*)/.exec(path) || []; - // carry over query if it exists - return `#/list${tail || ''}`; + urlForwarding.forwardApp(DASHBOARD_APP_ID, DASHBOARD_APP_ID, (path) => { + const [, tail] = /(\?.*)/.exec(path) || []; + // carry over query if it exists + return `#/list${tail || ''}`; + }); + urlForwarding.forwardApp(LEGACY_DASHBOARD_APP_ID, DASHBOARD_APP_ID, (path) => { + const [, id, tail] = /dashboard\/?(.*?)($|\?.*)/.exec(path) || []; + if (!id && !tail) { + // unrecognized sub url + return '#/list'; } - ); - urlForwarding.forwardApp( - DashboardConstants.DASHBOARD_ID, - DashboardConstants.DASHBOARDS_ID, - (path) => { - const [, id, tail] = /dashboard\/?(.*?)($|\?.*)/.exec(path) || []; - if (!id && !tail) { - // unrecognized sub url - return '#/list'; - } - if (!id && tail) { - // unsaved dashboard, but probably state in URL - return `#/create${tail || ''}`; - } - // persisted dashboard, probably with url state - return `#/view/${id}${tail || ''}`; + if (!id && tail) { + // unsaved dashboard, but probably state in URL + return `#/create${tail || ''}`; } - ); + // persisted dashboard, probably with url state + return `#/view/${id}${tail || ''}`; + }); if (home) { home.featureCatalogue.register({ - id: DashboardConstants.DASHBOARD_ID, - title: dashboardFeatureCatalog.getTitle(), - subtitle: dashboardFeatureCatalog.getSubtitle(), - description: dashboardFeatureCatalog.getDescription(), + id: LEGACY_DASHBOARD_APP_ID, + title: i18n.translate('dashboard.featureCatalogue.dashboardTitle', { + defaultMessage: 'Dashboard', + }), + subtitle: i18n.translate('dashboard.featureCatalogue.dashboardSubtitle', { + defaultMessage: 'Analyze data in dashboards.', + }), + description: i18n.translate('dashboard.featureCatalogue.dashboardDescription', { + defaultMessage: 'Display and share a collection of visualizations and saved searches.', + }), icon: 'dashboardApp', - path: `/app/dashboards#${DashboardConstants.LANDING_PAGE_PATH}`, + path: `/app/dashboards#${LANDING_PAGE_PATH}`, showOnHomePage: false, category: 'data', solutionId: 'kibana', @@ -293,7 +291,7 @@ export class DashboardPlugin public start(core: CoreStart, plugins: DashboardStartDependencies): DashboardStart { this.startDashboardKibanaServices(core, plugins, this.initializerContext).then(async () => { - const { buildAllDashboardActions } = await import('./application/actions'); + const { buildAllDashboardActions } = await import('./dashboard_actions'); buildAllDashboardActions({ core, plugins, @@ -302,18 +300,6 @@ export class DashboardPlugin }); return { - getDashboardContainerByValueRenderer: () => { - const dashboardContainerFactory = - plugins.embeddable.getEmbeddableFactory(DASHBOARD_CONTAINER_TYPE); - - if (!dashboardContainerFactory) { - throw new Error(`${DASHBOARD_CONTAINER_TYPE} Embeddable Factory not found`); - } - - return createDashboardContainerByValueRenderer({ - factory: dashboardContainerFactory as DashboardContainerFactory, - }); - }, locator: this.locator, dashboardFeatureFlagConfig: this.dashboardFeatureFlagConfig!, }; diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object.stub.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object.stub.ts index c23a76746404..5c3148743a32 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object.stub.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object.stub.ts @@ -8,10 +8,10 @@ import { savedObjectsServiceMock } from '@kbn/core/public/mocks'; import { PluginServiceFactory } from '@kbn/presentation-util-plugin/public'; -import { DashboardAttributes } from '../../application'; import { FindDashboardSavedObjectsResponse } from './lib/find_dashboard_saved_objects'; import { DashboardSavedObjectService } from './types'; +import { DashboardAttributes } from '../../../common'; import { LoadDashboardFromSavedObjectReturn } from './lib/load_dashboard_state_from_saved_object'; type DashboardSavedObjectServiceFactory = PluginServiceFactory; @@ -21,7 +21,7 @@ export const dashboardSavedObjectServiceFactory: DashboardSavedObjectServiceFact return { loadDashboardStateFromSavedObject: jest.fn().mockImplementation(() => Promise.resolve({ - dashboardState: {}, + dashboardInput: {}, } as LoadDashboardFromSavedObjectReturn) ), saveDashboardStateToSavedObject: jest.fn(), diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts index f64658802e0e..658fda8ddb46 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts @@ -15,8 +15,8 @@ import { findDashboardSavedObjects, findDashboardSavedObjectsByIds, } from './lib/find_dashboard_saved_objects'; -import { loadDashboardStateFromSavedObject } from './lib/load_dashboard_state_from_saved_object'; import { saveDashboardStateToSavedObject } from './lib/save_dashboard_state_to_saved_object'; +import { loadDashboardStateFromSavedObject } from './lib/load_dashboard_state_from_saved_object'; import type { DashboardSavedObjectRequiredServices, DashboardSavedObjectService } from './types'; export type DashboardSavedObjectServiceFactory = KibanaPluginServiceFactory< @@ -34,17 +34,16 @@ export const dashboardSavedObjectServiceFactory: DashboardSavedObjectServiceFact } = coreStart; return { - loadDashboardStateFromSavedObject: ({ id, getScopedHistory }) => + loadDashboardStateFromSavedObject: ({ id }) => loadDashboardStateFromSavedObject({ id, - getScopedHistory, savedObjectsClient, ...requiredServices, }), - saveDashboardStateToSavedObject: ({ currentState, redirectTo, saveOptions }) => + saveDashboardStateToSavedObject: ({ currentState, saveOptions, lastSavedId }) => saveDashboardStateToSavedObject({ - redirectTo, saveOptions, + lastSavedId, currentState, savedObjectsClient, ...requiredServices, diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/check_for_duplicate_dashboard_title.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/check_for_duplicate_dashboard_title.ts index e03345e78c41..2f106a2e1a00 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/check_for_duplicate_dashboard_title.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/check_for_duplicate_dashboard_title.ts @@ -8,8 +8,8 @@ import type { SavedObjectsClientContract } from '@kbn/core/public'; -import { DashboardConstants } from '../../..'; -import type { DashboardAttributes } from '../../../application'; +import { DashboardAttributes } from '../../../../common'; +import { DASHBOARD_SAVED_OBJECT_TYPE } from '../../../dashboard_constants'; export interface DashboardDuplicateTitleCheckProps { title: string; @@ -49,7 +49,7 @@ export async function checkForDuplicateDashboardTitle( fields: ['title'], search: `"${title}"`, searchFields: ['title'], - type: DashboardConstants.DASHBOARD_SAVED_OBJECT_TYPE, + type: DASHBOARD_SAVED_OBJECT_TYPE, }); const duplicate = response.savedObjects.find( (obj) => obj.get('title').toLowerCase() === title.toLowerCase() diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts index da677c444194..36f003119d6a 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts @@ -13,8 +13,8 @@ import { SimpleSavedObject, } from '@kbn/core/public'; -import { DashboardConstants } from '../../..'; -import type { DashboardAttributes } from '../../../application'; +import { DashboardAttributes } from '../../../../common'; +import { DASHBOARD_SAVED_OBJECT_TYPE } from '../../../dashboard_constants'; export interface FindDashboardSavedObjectsArgs { hasReference?: SavedObjectsFindOptionsReference[]; @@ -37,7 +37,7 @@ export async function findDashboardSavedObjects({ size, }: FindDashboardSavedObjectsArgs): Promise { const { total, savedObjects } = await savedObjectsClient.find({ - type: DashboardConstants.DASHBOARD_SAVED_OBJECT_TYPE, + type: DASHBOARD_SAVED_OBJECT_TYPE, search: search ? `${search}*` : undefined, searchFields: ['title^3', 'description'], defaultSearchOperator: 'AND' as 'AND', @@ -62,7 +62,7 @@ export async function findDashboardSavedObjectsByIds( ids: string[] ): Promise { const { savedObjects } = await savedObjectsClient.bulkGet( - ids.map((id) => ({ id, type: DashboardConstants.DASHBOARD_SAVED_OBJECT_TYPE })) + ids.map((id) => ({ id, type: DASHBOARD_SAVED_OBJECT_TYPE })) ); return savedObjects.map((savedObjectResult) => { diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/load_dashboard_state_from_saved_object.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/load_dashboard_state_from_saved_object.ts index 963814013dc3..16af08f728d6 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/load_dashboard_state_from_saved_object.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/load_dashboard_state_from_saved_object.ts @@ -5,57 +5,55 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { ReactElement } from 'react'; +import uuid from 'uuid'; +import { has } from 'lodash'; -import { Filter } from '@kbn/es-query'; +import { + ResolvedSimpleSavedObject, + SavedObjectAttributes, + SavedObjectsClientContract, +} from '@kbn/core/public'; +import { Filter, Query } from '@kbn/es-query'; import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; +import { cleanFiltersForSerialize } from '@kbn/presentation-util-plugin/public'; import { rawControlGroupAttributesToControlGroupInput } from '@kbn/controls-plugin/common'; import { parseSearchSourceJSON, injectSearchSourceReferences } from '@kbn/data-plugin/public'; -import { SavedObjectAttributes, SavedObjectsClientContract, ScopedHistory } from '@kbn/core/public'; - -import { migrateLegacyQuery } from '../../../application/lib/migrate_legacy_query'; import { - DashboardConstants, - defaultDashboardState, - createDashboardEditUrl, -} from '../../../dashboard_constants'; -import type { DashboardAttributes } from '../../../application'; + DashboardContainerByValueInput, + convertSavedPanelsToPanelMap, + DashboardAttributes, + DashboardOptions, + injectReferences, +} from '../../../../common'; import { DashboardSavedObjectRequiredServices } from '../types'; -import { DashboardOptions, DashboardState } from '../../../types'; -import { cleanFiltersForSerialize } from '../../../application/lib'; -import { convertSavedPanelsToPanelMap, injectReferences } from '../../../../common'; +import { DASHBOARD_SAVED_OBJECT_TYPE, DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants'; + +export function migrateLegacyQuery(query: Query | { [key: string]: any } | string): Query { + // Lucene was the only option before, so language-less queries are all lucene + if (!has(query, 'language')) { + return { query, language: 'lucene' }; + } + + return query as Query; +} export type LoadDashboardFromSavedObjectProps = DashboardSavedObjectRequiredServices & { id?: string; - getScopedHistory?: () => ScopedHistory; savedObjectsClient: SavedObjectsClientContract; }; export interface LoadDashboardFromSavedObjectReturn { - redirectedToAlias?: boolean; - dashboardState?: DashboardState; - createConflictWarning?: () => ReactElement | undefined; + dashboardFound: boolean; + dashboardId?: string; + resolveMeta?: Omit; + dashboardInput: DashboardContainerByValueInput; } -type SuccessfulLoadDashboardFromSavedObjectReturn = LoadDashboardFromSavedObjectReturn & { - dashboardState: DashboardState; -}; - -export const dashboardStateLoadWasSuccessful = ( - incoming?: LoadDashboardFromSavedObjectReturn -): incoming is SuccessfulLoadDashboardFromSavedObjectReturn => { - return Boolean(incoming && incoming?.dashboardState && !incoming.redirectedToAlias); -}; - export const loadDashboardStateFromSavedObject = async ({ savedObjectsTagging, savedObjectsClient, - getScopedHistory, - screenshotMode, embeddable, - spaces, data, id, }: LoadDashboardFromSavedObjectProps): Promise => { @@ -64,25 +62,26 @@ export const loadDashboardStateFromSavedObject = async ({ query: { queryString }, } = data; + const savedObjectId = id; + const embeddableId = uuid.v4(); + + const newDashboardState = { ...DEFAULT_DASHBOARD_INPUT, id: embeddableId }; + /** * This is a newly created dashboard, so there is no saved object state to load. */ - if (!id) return { dashboardState: defaultDashboardState }; + if (!savedObjectId) return { dashboardInput: newDashboardState, dashboardFound: true }; /** * Load the saved object */ - const { - outcome, - alias_purpose: aliasPurpose, - alias_target_id: aliasId, - saved_object: rawDashboardSavedObject, - } = await savedObjectsClient.resolve( - DashboardConstants.DASHBOARD_SAVED_OBJECT_TYPE, - id - ); + const { saved_object: rawDashboardSavedObject, ...resolveMeta } = + await savedObjectsClient.resolve( + DASHBOARD_SAVED_OBJECT_TYPE, + savedObjectId + ); if (!rawDashboardSavedObject._version) { - throw new SavedObjectNotFound(DashboardConstants.DASHBOARD_SAVED_OBJECT_TYPE, id); + return { dashboardInput: newDashboardState, dashboardFound: false, dashboardId: savedObjectId }; } /** @@ -99,33 +98,6 @@ export const loadDashboardStateFromSavedObject = async ({ ) as unknown as DashboardAttributes; })(); - /** - * Handle saved object resolve alias outcome by redirecting - */ - const scopedHistory = getScopedHistory?.(); - if (scopedHistory && outcome === 'aliasMatch' && id && aliasId) { - const path = scopedHistory.location.hash.replace(id, aliasId); - if (screenshotMode.isScreenshotMode()) { - scopedHistory.replace(path); - } else { - await spaces.redirectLegacyUrl?.({ path, aliasPurpose }); - } - return { redirectedToAlias: true }; - } - - /** - * Create conflict warning component if there is a saved object id conflict - */ - const createConflictWarning = - scopedHistory && outcome === 'conflict' && aliasId - ? () => - spaces.getLegacyUrlConflict?.({ - currentObjectId: id, - otherObjectId: aliasId, - otherObjectPath: `#${createDashboardEditUrl(aliasId)}${scopedHistory.location.search}`, - }) - : undefined; - /** * Create search source and pull filters and query from it. */ @@ -175,20 +147,22 @@ export const loadDashboardStateFromSavedObject = async ({ const panels = convertSavedPanelsToPanelMap(panelsJSON ? JSON.parse(panelsJSON) : []); return { - createConflictWarning, - dashboardState: { - ...defaultDashboardState, - - savedObjectId: id, + resolveMeta, + dashboardFound: true, + dashboardId: savedObjectId, + dashboardInput: { + ...DEFAULT_DASHBOARD_INPUT, + ...options, + + id: embeddableId, refreshInterval, timeRestore, description, timeRange, - options, filters, panels, - title, query, + title, viewMode: ViewMode.VIEW, // dashboards loaded from saved object default to view mode. If it was edited recently, the view mode from session storage will override this. tags: savedObjectsTagging.getTagIdsFromReferences?.(references) ?? [], diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/save_dashboard_state_to_saved_object.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/save_dashboard_state_to_saved_object.ts index 11c6988d22f9..fbb897fca68d 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/save_dashboard_state_to_saved_object.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/save_dashboard_state_to_saved_object.ts @@ -7,42 +7,71 @@ */ import { pick } from 'lodash'; +import moment, { Moment } from 'moment'; +import { + getDefaultControlGroupInput, + persistableControlGroupInputIsEqual, + controlGroupInputToRawControlGroupAttributes, +} from '@kbn/controls-plugin/common'; import { isFilterPinned } from '@kbn/es-query'; import { SavedObjectsClientContract } from '@kbn/core/public'; import { SavedObjectAttributes } from '@kbn/core-saved-objects-common'; - -import { extractSearchSourceReferences, RefreshInterval } from '@kbn/data-plugin/public'; import { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public'; +import { extractSearchSourceReferences, RefreshInterval } from '@kbn/data-plugin/public'; -import type { DashboardAttributes } from '../../../application'; +import { + extractReferences, + DashboardAttributes, + convertPanelMapToSavedPanels, + DashboardContainerByValueInput, +} from '../../../../common'; import { DashboardSavedObjectRequiredServices } from '../types'; -import { DashboardConstants } from '../../../dashboard_constants'; -import { convertTimeToUTCString } from '../../../application/lib'; -import { DashboardRedirect, DashboardState } from '../../../types'; -import { dashboardSaveToastStrings } from '../../../dashboard_strings'; -import { convertPanelMapToSavedPanels, extractReferences } from '../../../../common'; -import { serializeControlGroupInput } from '../../../application/lib/dashboard_control_group'; +import { DASHBOARD_SAVED_OBJECT_TYPE } from '../../../dashboard_constants'; +import { dashboardSaveToastStrings } from '../../../dashboard_container/_dashboard_container_strings'; export type SavedDashboardSaveOpts = SavedObjectSaveOpts & { saveAsCopy?: boolean }; export type SaveDashboardProps = DashboardSavedObjectRequiredServices & { - currentState: DashboardState; - redirectTo: DashboardRedirect; - saveOptions: SavedDashboardSaveOpts; savedObjectsClient: SavedObjectsClientContract; + currentState: DashboardContainerByValueInput; + saveOptions: SavedDashboardSaveOpts; + lastSavedId?: string; +}; + +export const serializeControlGroupInput = ( + controlGroupInput: DashboardContainerByValueInput['controlGroupInput'] +) => { + // only save to saved object if control group is not default + if ( + !controlGroupInput || + persistableControlGroupInputIsEqual(controlGroupInput, getDefaultControlGroupInput()) + ) { + return undefined; + } + return controlGroupInputToRawControlGroupAttributes(controlGroupInput); +}; + +export const convertTimeToUTCString = (time?: string | Moment): undefined | string => { + if (moment(time).isValid()) { + return moment(time).utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'); + } else { + // If it's not a valid moment date, then it should be a string representing a relative time + // like 'now' or 'now-15m'. + return time as string; + } }; export interface SaveDashboardReturn { id?: string; error?: string; - redirected?: boolean; + redirectRequired?: boolean; } export const saveDashboardStateToSavedObject = async ({ data, - redirectTo, embeddable, + lastSavedId, saveOptions, currentState, savedObjectsClient, @@ -64,11 +93,16 @@ export const saveDashboardStateToSavedObject = async ({ title, panels, filters, - options, timeRestore, description, - savedObjectId, controlGroupInput, + + // Dashboard options + useMargins, + syncColors, + syncCursor, + syncTooltips, + hidePanelTitles, } = currentState; /** @@ -90,7 +124,13 @@ export const saveDashboardStateToSavedObject = async ({ /** * Stringify options and panels */ - const optionsJSON = JSON.stringify(options); + const optionsJSON = JSON.stringify({ + useMargins, + syncColors, + syncCursor, + syncTooltips, + hidePanelTitles, + }); const panelsJSON = JSON.stringify(convertPanelMapToSavedPanels(panels, kibanaVersion)); /** @@ -111,10 +151,10 @@ export const saveDashboardStateToSavedObject = async ({ const rawDashboardAttributes: DashboardAttributes = { controlGroupInput: serializeControlGroupInput(controlGroupInput), kibanaSavedObjectMeta: { searchSourceJSON }, + description: description ?? '', refreshInterval, timeRestore, optionsJSON, - description, panelsJSON, timeFrom, title, @@ -139,17 +179,13 @@ export const saveDashboardStateToSavedObject = async ({ /** * Save the saved object using the saved objects client */ - const idToSaveTo = saveOptions.saveAsCopy ? undefined : savedObjectId; + const idToSaveTo = saveOptions.saveAsCopy ? undefined : lastSavedId; try { - const { id: newId } = await savedObjectsClient.create( - DashboardConstants.DASHBOARD_SAVED_OBJECT_TYPE, - attributes, - { - id: idToSaveTo, - overwrite: true, - references, - } - ); + const { id: newId } = await savedObjectsClient.create(DASHBOARD_SAVED_OBJECT_TYPE, attributes, { + id: idToSaveTo, + overwrite: true, + references, + }); if (newId) { toasts.addSuccess({ @@ -160,15 +196,9 @@ export const saveDashboardStateToSavedObject = async ({ /** * If the dashboard id has been changed, redirect to the new ID to keep the url param in sync. */ - if (newId !== savedObjectId) { - dashboardSessionStorage.clearState(savedObjectId); - redirectTo({ - id: newId, - editMode: true, - useReplace: true, - destination: 'dashboard', - }); - return { redirected: true, id: newId }; + if (newId !== lastSavedId) { + dashboardSessionStorage.clearState(lastSavedId); + return { redirectRequired: true, id: newId }; } } return { id: newId }; diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts index f7c00c3d31fb..08b9c2489cf1 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts @@ -42,14 +42,13 @@ export interface DashboardSavedObjectRequiredServices { savedObjectsTagging: DashboardSavedObjectsTaggingService; dashboardSessionStorage: DashboardSessionStorageServiceType; } - export interface DashboardSavedObjectService { loadDashboardStateFromSavedObject: ( - props: Pick + props: Pick ) => Promise; saveDashboardStateToSavedObject: ( - props: Pick + props: Pick ) => Promise; findDashboards: { findSavedObjects: ( @@ -64,3 +63,5 @@ export interface DashboardSavedObjectService { checkForDuplicateDashboardTitle: (meta: DashboardDuplicateTitleCheckProps) => Promise; savedObjectsClient: SavedObjectsClientContract; } + +export type { SaveDashboardReturn }; diff --git a/src/plugins/dashboard/public/services/dashboard_session_storage/dashboard_session_storage_service.ts b/src/plugins/dashboard/public/services/dashboard_session_storage/dashboard_session_storage_service.ts index 9b68eea95156..f949657f882d 100644 --- a/src/plugins/dashboard/public/services/dashboard_session_storage/dashboard_session_storage_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_session_storage/dashboard_session_storage_service.ts @@ -13,12 +13,12 @@ import { ViewMode } from '@kbn/embeddable-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import type { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public'; +import { DashboardSpacesService } from '../spaces/types'; import type { DashboardStartDependencies } from '../../plugin'; import type { DashboardSessionStorageServiceType } from './types'; -import { panelStorageErrorStrings } from '../../dashboard_strings'; -import type { DashboardState } from '../../types'; +import type { DashboardContainerByValueInput } from '../../../common'; import { DashboardNotificationsService } from '../notifications/types'; -import { DashboardSpacesService } from '../spaces/types'; +import { panelStorageErrorStrings } from '../../dashboard_container/_dashboard_container_strings'; export const DASHBOARD_PANELS_UNSAVED_ID = 'unsavedDashboard'; const DASHBOARD_PANELS_SESSION_KEY = 'dashboardStateManagerPanels'; @@ -68,7 +68,9 @@ class DashboardSessionStorageService implements DashboardSessionStorageServiceTy } } - public getState(id = DASHBOARD_PANELS_UNSAVED_ID): Partial | undefined { + public getState( + id = DASHBOARD_PANELS_UNSAVED_ID + ): Partial | undefined { try { return this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId]?.[id]; } catch (e) { @@ -79,7 +81,10 @@ class DashboardSessionStorageService implements DashboardSessionStorageServiceTy } } - public setState(id = DASHBOARD_PANELS_UNSAVED_ID, newState: Partial) { + public setState( + id = DASHBOARD_PANELS_UNSAVED_ID, + newState: Partial + ) { try { const sessionStateStorage = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) || {}; set(sessionStateStorage, [this.activeSpaceId, id], newState); diff --git a/src/plugins/dashboard/public/services/dashboard_session_storage/types.ts b/src/plugins/dashboard/public/services/dashboard_session_storage/types.ts index dae0d2e5a66c..bfd0dc765659 100644 --- a/src/plugins/dashboard/public/services/dashboard_session_storage/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_session_storage/types.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { DashboardState } from '../../types'; +import type { DashboardContainerByValueInput } from '../../../common'; export interface DashboardSessionStorageServiceType { clearState: (id?: string) => void; - getState: (id: string | undefined) => Partial | undefined; - setState: (id: string | undefined, newState: Partial) => void; + getState: (id: string | undefined) => Partial | undefined; + setState: (id: string | undefined, newState: Partial) => void; getDashboardIdsWithUnsavedChanges: () => string[]; dashboardHasUnsavedEdits: (id?: string) => boolean; } diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts deleted file mode 100644 index a9de47f63235..000000000000 --- a/src/plugins/dashboard/public/types.ts +++ /dev/null @@ -1,153 +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 { ReactElement } from 'react'; - -import { History } from 'history'; -import { AnyAction, Dispatch } from 'redux'; -import { BehaviorSubject, Subject } from 'rxjs'; - -import type { AppMountParameters, ScopedHistory, KibanaExecutionContext } from '@kbn/core/public'; -import type { Filter } from '@kbn/es-query'; -import type { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; -import { type EmbeddableInput, ViewMode } from '@kbn/embeddable-plugin/common'; -import type { ContainerInput } from '@kbn/embeddable-plugin/public'; -import type { DataView } from '@kbn/data-views-plugin/public'; -import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; -import type { RefreshInterval } from '@kbn/data-plugin/public'; -import type { Query, TimeRange } from '@kbn/es-query'; - -import type { DashboardContainer } from './application'; -import type { DashboardAppLocatorParams } from './locator'; -import type { DashboardPanelMap, DashboardPanelState, SavedDashboardPanel } from '../common'; - -export type { SavedDashboardPanel }; - -export type NavAction = (anchorElement?: any) => void; - -/** - * DashboardState contains all pieces of tracked state for an individual dashboard - */ -export interface DashboardState { - query: Query; - title: string; - tags: string[]; - filters: Filter[]; - viewMode: ViewMode; - description: string; - savedQuery?: string; - timeRestore: boolean; - timeRange?: TimeRange; - savedObjectId?: string; - fullScreenMode: boolean; - expandedPanelId?: string; - options: DashboardOptions; - panels: DashboardPanelMap; - refreshInterval?: RefreshInterval; - timeslice?: [number, number]; - - controlGroupInput?: PersistableControlGroupInput; -} - -/** - * RawDashboardState is the dashboard state as directly loaded from the panelJSON - */ -export type RawDashboardState = Omit & { panels: SavedDashboardPanel[] }; - -export interface DashboardContainerInput extends ContainerInput { - controlGroupInput?: PersistableControlGroupInput; - refreshConfig?: RefreshInterval; - isEmbeddedExternally?: boolean; - isFullScreenMode: boolean; - expandedPanelId?: string; - timeRange: TimeRange; - timeslice?: [number, number]; - timeRestore: boolean; - description?: string; - useMargins: boolean; - syncColors?: boolean; - syncTooltips?: boolean; - syncCursor?: boolean; - viewMode: ViewMode; - filters: Filter[]; - title: string; - query: Query; - panels: { - [panelId: string]: DashboardPanelState; - }; - executionContext?: KibanaExecutionContext; -} - -/** - * DashboardAppState contains all the tools the dashboard application uses to track, - * update, and view its state. - */ -export interface DashboardAppState { - hasUnsavedChanges?: boolean; - dataViews?: DataView[]; - updateLastSavedState?: () => void; - resetToLastSavedState?: () => void; - dashboardContainer?: DashboardContainer; - createConflictWarning?: () => ReactElement | undefined; - getLatestDashboardState?: () => DashboardState; - $triggerDashboardRefresh: Subject<{ force?: boolean }>; - $onDashboardStateChange: BehaviorSubject; -} - -/** - * The shared services and tools used to build a dashboard from a saved object ID. - */ -export interface DashboardBuildContext { - locatorState?: DashboardAppLocatorParams; - history: History; - isEmbeddedExternally: boolean; - kbnUrlStateStorage: IKbnUrlStateStorage; - $checkForUnsavedChanges: Subject; - getLatestDashboardState: () => DashboardState; - dispatchDashboardStateChange: Dispatch; - $triggerDashboardRefresh: Subject<{ force?: boolean }>; - $onDashboardStateChange: BehaviorSubject; - executionContext?: KibanaExecutionContext; -} - -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type DashboardOptions = { - hidePanelTitles: boolean; - useMargins: boolean; - syncColors: boolean; - syncCursor: boolean; - syncTooltips: boolean; -}; - -export type DashboardRedirect = (props: RedirectToProps) => void; -export type RedirectToProps = - | { destination: 'dashboard'; id?: string; useReplace?: boolean; editMode?: boolean } - | { destination: 'listing'; filter?: string; useReplace?: boolean }; - -export interface DashboardEmbedSettings { - forceHideFilterBar?: boolean; - forceShowTopNavMenu?: boolean; - forceShowQueryInput?: boolean; - forceShowDatePicker?: boolean; -} - -export interface DashboardSaveOptions { - newTitle: string; - newTags?: string[]; - newDescription: string; - newCopyOnSave: boolean; - newTimeRestore: boolean; - onTitleDuplicate: () => void; - isTitleDuplicateConfirmed: boolean; -} - -export interface DashboardMountContextProps { - restorePreviousUrl: () => void; - scopedHistory: () => ScopedHistory; - onAppLeave: AppMountParameters['onAppLeave']; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; -} diff --git a/src/plugins/dashboard/server/embeddable/dashboard_container_embeddable_factory.ts b/src/plugins/dashboard/server/dashboard_container/dashboard_container_embeddable_factory.ts similarity index 100% rename from src/plugins/dashboard/server/embeddable/dashboard_container_embeddable_factory.ts rename to src/plugins/dashboard/server/dashboard_container/dashboard_container_embeddable_factory.ts diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_saved_object.ts b/src/plugins/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/dashboard_saved_object.ts rename to src/plugins/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts diff --git a/src/plugins/dashboard/server/saved_objects/index.ts b/src/plugins/dashboard/server/dashboard_saved_object/index.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/index.ts rename to src/plugins/dashboard/server/dashboard_saved_object/index.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/dashboard_saved_object_migrations.test.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.test.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/dashboard_saved_object_migrations.test.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.test.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/dashboard_saved_object_migrations.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/dashboard_saved_object_migrations.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_by_value_dashboard_panels.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_by_value_dashboard_panels.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_extract_panel_references.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_extract_panel_references.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_extract_panel_references.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_extract_panel_references.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_hidden_titles.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_hidden_titles.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_index_pattern_reference.test.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_index_pattern_reference.test.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_index_pattern_reference.test.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_index_pattern_reference.test.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_index_pattern_reference.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_index_pattern_reference.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_index_pattern_reference.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_index_pattern_reference.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_match_all_query.test.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_match_all_query.test.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_match_all_query.test.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_match_all_query.test.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_match_all_query.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_match_all_query.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_match_all_query.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_match_all_query.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/index.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/index.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/index.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/index.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/migrate_to_730_panels.test.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrate_to_730_panels.test.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/migrate_to_730_panels.test.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrate_to_730_panels.test.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/migrate_to_730_panels.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrate_to_730_panels.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/migrate_to_730_panels.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrate_to_730_panels.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/migrations_700.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_700.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/migrations_700.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_700.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/migrations_730.test.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.test.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/migrations_730.test.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.test.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/migrations_730.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/migrations_730.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/move_filters_to_query.test.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/move_filters_to_query.test.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/move_filters_to_query.test.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/move_filters_to_query.test.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/move_filters_to_query.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/move_filters_to_query.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/move_filters_to_query.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/move_filters_to_query.ts diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/readme.md b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/readme.md similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/readme.md rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/readme.md diff --git a/src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/types.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/types.ts similarity index 100% rename from src/plugins/dashboard/server/saved_objects/migrations/migrate_to_730/types.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/types.ts diff --git a/src/plugins/dashboard/server/plugin.ts b/src/plugins/dashboard/server/plugin.ts index abb3dd1d9058..404b3ee998cc 100644 --- a/src/plugins/dashboard/server/plugin.ts +++ b/src/plugins/dashboard/server/plugin.ts @@ -14,12 +14,12 @@ import { TaskManagerSetupContract, TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; -import { createDashboardSavedObjectType } from './saved_objects'; +import { createDashboardSavedObjectType } from './dashboard_saved_object'; import { capabilitiesProvider } from './capabilities_provider'; import { DashboardPluginSetup, DashboardPluginStart } from './types'; import { registerDashboardUsageCollector } from './usage/register_collector'; -import { dashboardPersistableStateServiceFactory } from './embeddable/dashboard_container_embeddable_factory'; +import { dashboardPersistableStateServiceFactory } from './dashboard_container/dashboard_container_embeddable_factory'; import { getUISettings } from './ui_settings'; import { diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 278494b9df55..d04350ccf9e5 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -64,7 +64,7 @@ export abstract class Container< output: TContainerOutput, protected readonly getFactory: EmbeddableStart['getEmbeddableFactory'], parent?: IContainer, - settings?: EmbeddableContainerSettings + settings?: EmbeddableContainerSettings ) { super(input, output, parent); this.getFactory = getFactory; // Currently required for using in storybook due to https://github.com/storybookjs/storybook/issues/13834 @@ -74,11 +74,14 @@ export abstract class Container< settings?.initializeSequentially || settings?.childIdInitializeOrder ); - // initialize all children on the first input change. - const init$ = this.getInput$().pipe( + const initSource = settings?.readyToInitializeChildren$ + ? settings?.readyToInitializeChildren$ + : this.getInput$(); + + const init$ = initSource.pipe( take(1), - mergeMap(async () => { - const initPromise = this.initializeChildEmbeddables(input, settings); + mergeMap(async (currentInput) => { + const initPromise = this.initializeChildEmbeddables(currentInput, settings); if (awaitingInitialize) await initPromise; }) ); @@ -369,7 +372,7 @@ export abstract class Container< private async initializeChildEmbeddables( initialInput: TContainerInput, - initializeSettings?: EmbeddableContainerSettings + initializeSettings?: EmbeddableContainerSettings ) { let initializeOrder = Object.keys(initialInput.panels); diff --git a/src/plugins/embeddable/public/lib/containers/i_container.ts b/src/plugins/embeddable/public/lib/containers/i_container.ts index 5539f854b24d..f7ae3d64c6a2 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { Observable } from 'rxjs'; + import { Embeddable, EmbeddableInput, @@ -28,7 +30,7 @@ export interface ContainerInput extends EmbeddableInput }; } -export interface EmbeddableContainerSettings { +export interface EmbeddableContainerSettings { /** * If true, the container will wait for each embeddable to load after creation before loading the next embeddable. */ @@ -37,6 +39,10 @@ export interface EmbeddableContainerSettings { * Initialise children in the order specified. If an ID does not match it will be skipped and if a child is not included it will be initialized in the default order after the list of provided IDs. */ childIdInitializeOrder?: string[]; + /** + * + */ + readyToInitializeChildren$?: Observable; } export interface IContainer< diff --git a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss index c66cdce519a3..80814427c937 100644 --- a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss +++ b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss @@ -76,7 +76,7 @@ } } -.embPanel__dragger:not(.embPanel__title) { +.embPanel--dragHandle:not(.embPanel__title) { flex-grow: 1; } @@ -146,7 +146,7 @@ @include euiSlightShadowHover; } - .embPanel__dragger { + .embPanel--dragHandle { transition: background-color $euiAnimSpeedFast $euiAnimSlightResistance; &:hover { diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts index 34414f119496..ea8134310097 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts @@ -8,26 +8,14 @@ import { i18n } from '@kbn/i18n'; import { Action, IncompatibleActionError } from '../../../ui_actions'; -import { ContainerInput, IContainer } from '../../../containers'; import { ViewMode } from '../../../types'; import { IEmbeddable } from '../../../embeddables'; export const REMOVE_PANEL_ACTION = 'deletePanel'; -interface ExpandedPanelInput extends ContainerInput { - expandedPanelId: string; -} - interface ActionContext { embeddable: IEmbeddable; } - -function hasExpandedPanelInput( - container: IContainer -): container is IContainer<{}, ExpandedPanelInput> { - return (container as IContainer<{}, ExpandedPanelInput>).getInput().expandedPanelId !== undefined; -} - export class RemovePanelAction implements Action { public readonly type = REMOVE_PANEL_ACTION; public readonly id = REMOVE_PANEL_ACTION; @@ -47,9 +35,11 @@ export class RemovePanelAction implements Action { public async isCompatible({ embeddable }: ActionContext) { const isPanelExpanded = - embeddable.parent && - hasExpandedPanelInput(embeddable.parent) && - embeddable.parent.getInput().expandedPanelId === embeddable.id; + // TODO - we need a common embeddable extension pattern to allow actions to call methods on generic embeddables + // Casting to a type that has the method will do for now. + ( + embeddable.parent as unknown as { getExpandedPanelId: () => string | undefined } + )?.getExpandedPanelId?.() === embeddable.id; return Boolean( embeddable.parent && embeddable.getInput().viewMode === ViewMode.EDIT && !isPanelExpanded diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 6a75b0302b3c..07cfa6274952 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -216,12 +216,14 @@ export function PanelHeader({ ); }; + const titleClasses = classNames('embPanel__title', { 'embPanel--dragHandle': !isViewMode }); + return (
-

+

{getAriaLabel()} {renderTitle()} {renderBadges(badges, embeddable)} diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx index 18dc9778bc3e..0758103b6f63 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx @@ -42,7 +42,9 @@ export class HelloWorldContainer extends Container, private readonly options: HelloWorldContainerOptions, - initializeSettings?: EmbeddableContainerSettings + initializeSettings?: EmbeddableContainerSettings< + ContainerInput<{ firstName: string; lastName: string }> + > ) { super( input, diff --git a/src/plugins/embeddable/public/tests/container.test.ts b/src/plugins/embeddable/public/tests/container.test.ts index c6088e95069c..177b1a618282 100644 --- a/src/plugins/embeddable/public/tests/container.test.ts +++ b/src/plugins/embeddable/public/tests/container.test.ts @@ -45,7 +45,7 @@ import { EmbeddableContainerSettings } from '../lib/containers/i_container'; async function createHelloWorldContainerAndEmbeddable( containerInput: ContainerInput = { id: 'hello', panels: {} }, embeddableInput = {}, - settings?: EmbeddableContainerSettings + settings?: EmbeddableContainerSettings> ) { const coreSetup = coreMock.createSetup(); const coreStart = coreMock.createStart(); diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index f4b741e2ca94..d98bf8aa05d7 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -39,9 +39,9 @@ export { } from './components'; export { - useReduxContainerContext, useReduxEmbeddableContext, lazyLoadReduxEmbeddablePackage, + cleanFiltersForSerialize, type ReduxEmbeddableState, type ReduxEmbeddableTools, type ReduxEmbeddablePackage, diff --git a/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx b/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx index f6400d1424ff..426cb073b55f 100644 --- a/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx +++ b/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx @@ -7,16 +7,18 @@ */ import { + AnyAction, configureStore, createSlice, Draft, + Middleware, PayloadAction, SliceCaseReducers, } from '@reduxjs/toolkit'; import React, { ReactNode, PropsWithChildren } from 'react'; import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; -import { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { Embeddable } from '@kbn/embeddable-plugin/public'; import { EmbeddableReducers, @@ -36,12 +38,14 @@ export const createReduxEmbeddableTools = < reducers, embeddable, syncSettings, + additionalMiddleware, initialComponentState, }: { - embeddable: IEmbeddable< + embeddable: Embeddable< ReduxEmbeddableStateType['explicitInput'], ReduxEmbeddableStateType['output'] >; + additionalMiddleware?: Array>; initialComponentState?: ReduxEmbeddableStateType['componentState']; syncSettings?: ReduxEmbeddableSyncSettings; reducers: ReducerType; @@ -78,27 +82,22 @@ export const createReduxEmbeddableTools = < reducers: { ...reducers, ...genericReducers }, }); - const store = configureStore({ reducer: slice.reducer }); + const store = configureStore({ + reducer: slice.reducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(...(additionalMiddleware ?? [])), + }); // create the context which will wrap this embeddable's react components to allow access to update and read from the store. const context = { + embeddableInstance: embeddable, + actions: slice.actions as ReduxEmbeddableContext< ReduxEmbeddableStateType, typeof reducers >['actions'], useEmbeddableDispatch: () => useDispatch(), useEmbeddableSelector: useSelector as TypedUseSelectorHook, - - // populate container actions for embeddables which are Containers - containerActions: embeddable.getIsContainer() - ? { - untilEmbeddableLoaded: embeddable.untilEmbeddableLoaded.bind(embeddable), - updateInputForChild: embeddable.updateInputForChild.bind(embeddable), - removeEmbeddable: embeddable.removeEmbeddable.bind(embeddable), - addNewEmbeddable: embeddable.addNewEmbeddable.bind(embeddable), - replaceEmbeddable: embeddable.replaceEmbeddable.bind(embeddable), - } - : undefined, }; const Wrapper: React.FC> = ({ children }: { children?: ReactNode }) => ( @@ -120,6 +119,7 @@ export const createReduxEmbeddableTools = < actions: context.actions, dispatch: store.dispatch, getState: store.getState, + onStateChange: store.subscribe, cleanup: () => stopReduxEmbeddableSync?.(), }; }; diff --git a/src/plugins/presentation_util/public/redux_embeddables/index.ts b/src/plugins/presentation_util/public/redux_embeddables/index.ts index f73322a7ca26..d18715cc814d 100644 --- a/src/plugins/presentation_util/public/redux_embeddables/index.ts +++ b/src/plugins/presentation_util/public/redux_embeddables/index.ts @@ -8,12 +8,10 @@ import { ReduxEmbeddablePackage } from './types'; -export { - useReduxContainerContext, - useReduxEmbeddableContext, -} from './use_redux_embeddable_context'; +export { useReduxEmbeddableContext } from './use_redux_embeddable_context'; export type { ReduxEmbeddableState, ReduxEmbeddableTools, ReduxEmbeddablePackage } from './types'; +export { cleanFiltersForSerialize } from './clean_redux_embeddable_state'; export const lazyLoadReduxEmbeddablePackage = async (): Promise => { const { createReduxEmbeddableTools } = await import('./create_redux_embeddable_tools'); diff --git a/src/plugins/presentation_util/public/redux_embeddables/types.ts b/src/plugins/presentation_util/public/redux_embeddables/types.ts index eaa53bba2454..84e088328b7f 100644 --- a/src/plugins/presentation_util/public/redux_embeddables/types.ts +++ b/src/plugins/presentation_util/public/redux_embeddables/types.ts @@ -15,7 +15,7 @@ import { EnhancedStore, } from '@reduxjs/toolkit'; import { TypedUseSelectorHook } from 'react-redux'; -import { EmbeddableInput, EmbeddableOutput, IContainer } from '@kbn/embeddable-plugin/public'; +import { EmbeddableInput, EmbeddableOutput, Embeddable } from '@kbn/embeddable-plugin/public'; import { PropsWithChildren } from 'react'; export interface ReduxEmbeddableSyncSettings< @@ -51,6 +51,7 @@ export interface ReduxEmbeddableTools< Wrapper: React.FC>; dispatch: EnhancedStore['dispatch']; getState: EnhancedStore['getState']; + onStateChange: EnhancedStore['subscribe']; actions: ReduxEmbeddableContext['actions']; } @@ -89,7 +90,11 @@ export interface EmbeddableReducers< */ export interface ReduxEmbeddableContext< ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, - ReducerType extends EmbeddableReducers = EmbeddableReducers + ReducerType extends EmbeddableReducers = EmbeddableReducers, + EmbeddableType extends Embeddable< + ReduxEmbeddableStateType['explicitInput'], + ReduxEmbeddableStateType['output'] + > = Embeddable > { actions: { [Property in keyof ReducerType]: ActionCreatorWithPayload< @@ -104,20 +109,7 @@ export interface ReduxEmbeddableContext< Partial >; }; + embeddableInstance: EmbeddableType; useEmbeddableSelector: TypedUseSelectorHook; useEmbeddableDispatch: () => Dispatch; } - -export type ReduxContainerContext< - ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, - ReducerType extends EmbeddableReducers = EmbeddableReducers -> = ReduxEmbeddableContext & { - containerActions: Pick< - IContainer, - | 'untilEmbeddableLoaded' - | 'removeEmbeddable' - | 'addNewEmbeddable' - | 'updateInputForChild' - | 'replaceEmbeddable' - >; -}; diff --git a/src/plugins/presentation_util/public/redux_embeddables/use_redux_embeddable_context.ts b/src/plugins/presentation_util/public/redux_embeddables/use_redux_embeddable_context.ts index 4454d090a956..6463ea306d3f 100644 --- a/src/plugins/presentation_util/public/redux_embeddables/use_redux_embeddable_context.ts +++ b/src/plugins/presentation_util/public/redux_embeddables/use_redux_embeddable_context.ts @@ -5,22 +5,17 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { Embeddable } from '@kbn/embeddable-plugin/public'; import { createContext, useContext } from 'react'; -import type { - ReduxEmbeddableState, - ReduxContainerContext, - ReduxEmbeddableContext, - EmbeddableReducers, -} from './types'; +import type { ReduxEmbeddableState, ReduxEmbeddableContext, EmbeddableReducers } from './types'; /** * When creating the context, a generic EmbeddableInput as placeholder is used. This will later be cast to - * the type passed in by the useReduxEmbeddableContext or useReduxContainerContext hooks + * the type passed in by the useReduxEmbeddableContext hook **/ -export const EmbeddableReduxContext = createContext< - ReduxEmbeddableContext | ReduxContainerContext | null ->(null); +export const EmbeddableReduxContext = + createContext | null>(null); /** * A typed use context hook for embeddables that are not containers. it @returns an @@ -30,11 +25,17 @@ export const EmbeddableReduxContext = createContext< */ export const useReduxEmbeddableContext = < ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, - ReducerType extends EmbeddableReducers = EmbeddableReducers ->(): ReduxEmbeddableContext => { - const context = useContext>( + ReducerType extends EmbeddableReducers = EmbeddableReducers, + EmbeddableType extends Embeddable = Embeddable< + ReduxEmbeddableStateType['explicitInput'], + ReduxEmbeddableStateType['output'] + > +>(): ReduxEmbeddableContext => { + const context = useContext< + ReduxEmbeddableContext + >( EmbeddableReduxContext as unknown as React.Context< - ReduxEmbeddableContext + ReduxEmbeddableContext > ); if (context == null) { @@ -45,27 +46,3 @@ export const useReduxEmbeddableContext = < return context!; }; - -/** - * A typed use context hook for embeddable containers. it @returns an - * ReduxContainerContextServices object typed to the generic inputTypes and ReducerTypes you pass in. - * Note that the reducer type is optional, but will be required to correctly infer the keys and payload - * types of your reducers. use `typeof MyReducers` here to retain them. It also includes a containerActions - * key which contains most of the commonly used container operations - */ -export const useReduxContainerContext = < - ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, - ReducerType extends EmbeddableReducers = EmbeddableReducers ->(): ReduxContainerContext => { - const context = useContext>( - EmbeddableReduxContext as unknown as React.Context< - ReduxContainerContext - > - ); - if (context == null) { - throw new Error( - 'useReduxEmbeddableContext must be used inside the ReduxEmbeddableWrapper from build_redux_embeddable_context.' - ); - } - return context!; -}; diff --git a/test/examples/embeddables/dashboard.ts b/test/examples/embeddables/dashboard.ts deleted file mode 100644 index 915867ccd57a..000000000000 --- a/test/examples/embeddables/dashboard.ts +++ /dev/null @@ -1,136 +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 { PluginFunctionalProviderContext } from '../../plugin_functional/services'; - -export const testDashboardInput = { - panels: { - '1': { - gridData: { - w: 24, - h: 15, - x: 0, - y: 15, - i: '1', - }, - type: 'HELLO_WORLD_EMBEDDABLE', - explicitInput: { - id: '1', - }, - }, - '822cd0f0-ce7c-419d-aeaa-1171cf452745': { - gridData: { - w: 24, - h: 15, - x: 0, - y: 0, - i: '822cd0f0-ce7c-419d-aeaa-1171cf452745', - }, - type: 'visualization', - explicitInput: { - id: '822cd0f0-ce7c-419d-aeaa-1171cf452745', - savedObjectId: '3fe22200-3dcb-11e8-8660-4d65aa086b3c', - }, - }, - '66f0a265-7b06-4974-accd-d05f74f7aa82': { - gridData: { - w: 24, - h: 15, - x: 24, - y: 0, - i: '66f0a265-7b06-4974-accd-d05f74f7aa82', - }, - type: 'visualization', - explicitInput: { - id: '66f0a265-7b06-4974-accd-d05f74f7aa82', - savedObjectId: '4c0f47e0-3dcd-11e8-8660-4d65aa086b3c', - }, - }, - 'b2861741-40b9-4dc8-b82b-080c6e29a551': { - gridData: { - w: 24, - h: 15, - x: 0, - y: 15, - i: 'b2861741-40b9-4dc8-b82b-080c6e29a551', - }, - type: 'search', - explicitInput: { - id: 'b2861741-40b9-4dc8-b82b-080c6e29a551', - savedObjectId: 'be5accf0-3dca-11e8-8660-4d65aa086b3c', - }, - }, - }, - isEmbeddedExternally: false, - isFullScreenMode: false, - filters: [], - useMargins: true, - id: '', - hidePanelTitles: false, - query: { - query: '', - language: 'kuery', - }, - timeRange: { - from: '2017-10-01T20:20:36.275Z', - to: '2019-02-04T21:20:55.548Z', - }, - refreshConfig: { - value: 0, - pause: true, - }, - viewMode: 'edit', - lastReloadRequestTime: 1556569306103, - title: 'New Dashboard', - description: '', -}; - -// eslint-disable-next-line import/no-default-export -export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { - const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); - const testSubjects = getService('testSubjects'); - const pieChart = getService('pieChart'); - const dashboardExpect = getService('dashboardExpect'); - const elasticChart = getService('elasticChart'); - const PageObjects = getPageObjects(['common', 'visChart', 'dashboard']); - const monacoEditor = getService('monacoEditor'); - - describe('dashboard container', () => { - before(async () => { - 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 PageObjects.common.navigateToApp('dashboardEmbeddableExamples'); - await testSubjects.click('dashboardEmbeddableByValue'); - await PageObjects.dashboard.waitForRenderComplete(); - - await updateInput(JSON.stringify(testDashboardInput, null, 4)); - }); - - it('pie charts', async () => { - await elasticChart.setNewChartUiDebugFlag(); - await pieChart.expectPieSliceCount(5); - }); - - it('markdown', async () => { - await dashboardExpect.markdownWithValuesExists(["I'm a markdown!"]); - }); - - it('saved search', async () => { - await dashboardExpect.savedSearchRowCount(10); - }); - }); - - async function updateInput(input: string) { - await monacoEditor.setCodeEditorValue(input); - await testSubjects.click('dashboardEmbeddableByValueInputSubmit'); - } -} diff --git a/test/examples/embeddables/index.ts b/test/examples/embeddables/index.ts index 6cd95c699e7b..3569344e861c 100644 --- a/test/examples/embeddables/index.ts +++ b/test/examples/embeddables/index.ts @@ -27,6 +27,5 @@ export default function ({ loadTestFile(require.resolve('./todo_embeddable')); loadTestFile(require.resolve('./list_container')); loadTestFile(require.resolve('./adding_children')); - loadTestFile(require.resolve('./dashboard')); }); } diff --git a/test/functional/apps/dashboard/group1/dashboard_unsaved_listing.ts b/test/functional/apps/dashboard/group1/dashboard_unsaved_listing.ts index 6b55a44ff9e7..b300542d0cc8 100644 --- a/test/functional/apps/dashboard/group1/dashboard_unsaved_listing.ts +++ b/test/functional/apps/dashboard/group1/dashboard_unsaved_listing.ts @@ -147,6 +147,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.saveVisualizationExpectSuccess('Wildvis', { redirectToOrigin: true, }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); // ensure that the unsaved listing exists await PageObjects.dashboard.gotoDashboardLandingPage(); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx index c529eb11d085..27aad8722294 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx @@ -10,13 +10,11 @@ import { EmbeddableToDashboardDrilldown } from './embeddable_to_dashboard_drilld import { AbstractDashboardDrilldownConfig as Config } from '../abstract_dashboard_drilldown'; import { savedObjectsServiceMock } from '@kbn/core/public/mocks'; import { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public'; -import { - DashboardAppLocatorDefinition, - DashboardAppLocatorParams, -} from '@kbn/dashboard-plugin/public/locator'; +import { DashboardAppLocatorParams } from '@kbn/dashboard-plugin/public'; import { StartDependencies } from '../../../plugin'; import { StartServicesGetter } from '@kbn/kibana-utils-plugin/public/core'; import { EnhancedEmbeddableContext } from '@kbn/embeddable-enhanced-plugin/public'; +import { DashboardAppLocatorDefinition } from '@kbn/dashboard-plugin/public/dashboard_app/locator/locator'; describe('.isConfigValid()', () => { const drilldown = new EmbeddableToDashboardDrilldown({} as any); 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 5452e2620745..39c0b07ddd4a 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 @@ -97,7 +97,6 @@ export class EmbeddableToDashboardDrilldown extends AbstractDashboardDrilldown !isFilterPinned(f)), - savedQuery: state.savedQuery, }), { useHash: false, storeInHashQuery: true }, location.path diff --git a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_add_to_dashboard_actions.tsx b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_add_to_dashboard_actions.tsx index 449d2bc5cae6..b47dea3a5e95 100644 --- a/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_add_to_dashboard_actions.tsx +++ b/x-pack/plugins/ml/public/application/explorer/dashboard_controls/use_add_to_dashboard_actions.tsx @@ -6,7 +6,7 @@ */ import { useCallback } from 'react'; -import { DashboardConstants } from '@kbn/dashboard-plugin/public'; +import { DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; import { DashboardItem } from './use_dashboards_table'; import { useMlKibana } from '../../contexts/kibana'; import { useDashboardService } from '../../services/dashboard_service'; @@ -39,7 +39,7 @@ export function useAddToDashboardActions< const dashboardPath = await dashboardService.getDashboardEditUrl(selectedDashboardId); - await stateTransfer.navigateToWithEmbeddablePackage(DashboardConstants.DASHBOARDS_ID, { + await stateTransfer.navigateToWithEmbeddablePackage(DASHBOARD_APP_ID, { path: dashboardPath, state: { type, diff --git a/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx index 06128b7fa0ca..8b18094d1301 100644 --- a/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx @@ -9,7 +9,7 @@ import { Filter, FilterStateStore } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { createAction } from '@kbn/ui-actions-plugin/public'; import { firstValueFrom } from 'rxjs'; -import { DashboardConstants } from '@kbn/dashboard-plugin/public'; +import { DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; import { MlCoreSetup } from '../plugin'; import { SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from '../application/explorer/explorer_constants'; import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, SwimLaneDrilldownContext } from '../embeddables'; @@ -17,7 +17,7 @@ import { CONTROLLED_BY_SWIM_LANE_FILTER } from './constants'; export const APPLY_INFLUENCER_FILTERS_ACTION = 'applyInfluencerFiltersAction'; -const supportedApps = [DashboardConstants.DASHBOARDS_ID]; +const supportedApps = [DASHBOARD_APP_ID]; export function createApplyInfluencerFiltersAction( getStartServices: MlCoreSetup['getStartServices'] diff --git a/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx index 6f3f9a025858..d90bc9458fef 100644 --- a/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx @@ -8,14 +8,14 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { createAction } from '@kbn/ui-actions-plugin/public'; +import { DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; import { firstValueFrom } from 'rxjs'; -import { DashboardConstants } from '@kbn/dashboard-plugin/public'; import { MlCoreSetup } from '../plugin'; import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, SwimLaneDrilldownContext } from '../embeddables'; export const APPLY_TIME_RANGE_SELECTION_ACTION = 'applyTimeRangeSelectionAction'; -const supportedApps = [DashboardConstants.DASHBOARDS_ID]; +const supportedApps = [DASHBOARD_APP_ID]; export function createApplyTimeRangeSelectionAction( getStartServices: MlCoreSetup['getStartServices'] diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx index fa38c1e673ba..2123fbf770e9 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx @@ -14,7 +14,7 @@ import { } from '@elastic/eui'; import React from 'react'; import type { DashboardCapabilities } from '@kbn/dashboard-plugin/common/types'; -import { DashboardConstants } from '@kbn/dashboard-plugin/public'; +import { LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; import { SecurityPageName } from '../../app/types'; import { DashboardsTable } from '../../common/components/dashboards/dashboards_table'; import { Title } from '../../common/components/header_page/title'; @@ -60,7 +60,7 @@ const Header: React.FC<{ canCreateDashboard: boolean }> = ({ canCreateDashboard export const DashboardsLandingPage = () => { const dashboardLinks = useAppRootNavLink(SecurityPageName.dashboardsLanding)?.links ?? []; const { show: canReadDashboard, createNew: canCreateDashboard } = - useCapabilities(DashboardConstants.DASHBOARD_ID); + useCapabilities(LEGACY_DASHBOARD_APP_ID); return ( diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 4d232284244a..13f0a63ab195 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -1044,7 +1044,6 @@ "dashboard.listing.unsaved.editAria": "Poursuivre les modifications apportées à {title}", "dashboard.listing.unsaved.unsavedChangesTitle": "Vous avez des modifications non enregistrées dans le {dash} suivant :", "dashboard.loadingError.dashboardGridErrorMessage": "Impossible de charger le tableau de bord : {message}", - "dashboard.loadingError.errorMessage": "Erreur rencontrée lors du chargement du tableau de bord enregistré : {message}", "dashboard.noMatchRoute.bannerText": "L'application de tableau de bord ne reconnaît pas ce chemin : {route}.", "dashboard.panel.addToLibrary.successMessage": "Le panneau {panelTitle} a été ajouté à la bibliothèque Visualize.", "dashboard.panel.unableToMigratePanelDataForSixThreeZeroErrorMessage": "Impossible de migrer les données du panneau pour une rétro-compatibilité \"6.3.0\". Le panneau ne contient pas le champ attendu : {key}.", @@ -1067,11 +1066,6 @@ "dashboard.appLeaveConfirmModal.unsavedChangesTitle": "Modifications non enregistrées", "dashboard.badge.readOnly.text": "Lecture seule", "dashboard.badge.readOnly.tooltip": "Impossible d'enregistrer les tableaux de bord", - "dashboard.changeViewModeConfirmModal.cancelButtonLabel": "Poursuivre les modifications", - "dashboard.changeViewModeConfirmModal.confirmButtonLabel": "Ignorer les modifications", - "dashboard.changeViewModeConfirmModal.description": "Vous pouvez conserver ou ignorer vos modifications lors du retour en mode Affichage. Les modifications ignorées ne peuvent toutefois pas être récupérées.", - "dashboard.changeViewModeConfirmModal.keepUnsavedChangesButtonLabel": "Conserver les modifications", - "dashboard.changeViewModeConfirmModal.leaveEditModeTitle": "Vous avez des modifications non enregistrées.", "dashboard.cloneModal.cloneDashboardTitleAriaLabel": "Titre du tableau de bord cloné", "dashboard.createConfirmModal.cancelButtonLabel": "Annuler", "dashboard.createConfirmModal.confirmButtonLabel": "Redémarrer", @@ -1102,7 +1096,6 @@ "dashboard.featureCatalogue.dashboardSubtitle": "Analysez des données à l’aide de tableaux de bord.", "dashboard.featureCatalogue.dashboardTitle": "Tableau de bord", "dashboard.fillDashboardTitle": "Ce tableau de bord est vide. Remplissons-le.", - "dashboard.helpMenu.appName": "Tableaux de bord", "dashboard.howToStartWorkingOnNewDashboardDescription": "Cliquez sur Modifier dans la barre de menu ci-dessus pour commencer à ajouter des panneaux.", "dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel": "Modifier le tableau de bord", "dashboard.labs.enableLabsDescription": "Cet indicateur détermine si l'utilisateur a accès au bouton Ateliers, moyen rapide d'activer et de désactiver les fonctionnalités de la version d'évaluation technique dans le tableau de bord.", @@ -1120,7 +1113,6 @@ "dashboard.listing.unsaved.editTitle": "Poursuivre les modifications", "dashboard.listing.unsaved.loading": "Chargement", "dashboard.loadURLError.PanelTooOld": "Impossible de charger les panneaux à partir d'une URL créée dans une version antérieure à 7.3", - "dashboard.migratedChanges": "Certains des panneaux ont été mis à jour vers la version la plus récente.", "dashboard.noMatchRoute.bannerTitleText": "Page introuvable", "dashboard.panel.AddToLibrary": "Enregistrer dans la bibliothèque", "dashboard.panel.clonedToast": "Panneau cloné", @@ -1181,7 +1173,6 @@ "dashboard.topNave.shareConfigDescription": "Partager le tableau de bord", "dashboard.topNave.viewConfigDescription": "Basculer en mode Affichage uniquement", "dashboard.unsavedChangesBadge": "Modifications non enregistrées", - "dashboard.urlWasRemovedInSixZeroWarningMessage": "L'url \"dashboard/create\" a été supprimée dans la version 6.0. Veuillez mettre vos signets à jour.", "data.advancedSettings.autocompleteIgnoreTimerangeText": "Désactivez cette propriété pour obtenir des suggestions de saisie semi-automatique depuis l’intégralité de l’ensemble de données plutôt que depuis la plage temporelle définie. {learnMoreLink}", "data.advancedSettings.autocompleteValueSuggestionMethodText": "La méthode utilisée pour générer des suggestions de valeur pour la saisie semi-automatique KQL. Sélectionnez terms_enum pour utiliser l'API d'énumération de termes d'Elasticsearch afin d’améliorer les performances de suggestion de saisie semi-automatique. (Notez que terms_enum est incompatible avec la sécurité au niveau du document.) Sélectionnez terms_agg pour utiliser l'agrégation de termes d'Elasticsearch. {learnMoreLink}", "data.advancedSettings.courier.customRequestPreferenceText": "{requestPreferenceLink} utilisé lorsque {setRequestReferenceSetting} est défini sur {customSettingValue}.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2f8fe3338e9b..b4d7172f3a52 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1044,7 +1044,6 @@ "dashboard.listing.unsaved.editAria": "{title}の編集を続行", "dashboard.listing.unsaved.unsavedChangesTitle": "次の{dash}には保存されていない変更があります。", "dashboard.loadingError.dashboardGridErrorMessage": "ダッシュボードを読み込めません:{message}", - "dashboard.loadingError.errorMessage": "保存されたダッシュボードの読み込み中にエラーが発生しました:{message}", "dashboard.noMatchRoute.bannerText": "ダッシュボードアプリケーションはこのルート{route}を認識できません。", "dashboard.panel.addToLibrary.successMessage": "パネル {panelTitle} は Visualize ライブラリに追加されました", "dashboard.panel.unableToMigratePanelDataForSixThreeZeroErrorMessage": "「6.3.0」のダッシュボードの互換性のため、パネルデータを移行できませんでした。パネルに必要なフィールドがありません:{key}", @@ -1067,11 +1066,6 @@ "dashboard.appLeaveConfirmModal.unsavedChangesTitle": "保存されていない変更", "dashboard.badge.readOnly.text": "読み取り専用", "dashboard.badge.readOnly.tooltip": "ダッシュボードを保存できません", - "dashboard.changeViewModeConfirmModal.cancelButtonLabel": "編集を続行", - "dashboard.changeViewModeConfirmModal.confirmButtonLabel": "変更を破棄", - "dashboard.changeViewModeConfirmModal.description": "表示モードに戻ったときに変更内容を保持または破棄できます。 破棄された変更を回復することはできません。", - "dashboard.changeViewModeConfirmModal.keepUnsavedChangesButtonLabel": "変更を保持", - "dashboard.changeViewModeConfirmModal.leaveEditModeTitle": "保存されていない変更があります", "dashboard.cloneModal.cloneDashboardTitleAriaLabel": "クローンダッシュボードタイトル", "dashboard.createConfirmModal.cancelButtonLabel": "キャンセル", "dashboard.createConfirmModal.confirmButtonLabel": "やり直す", @@ -1102,7 +1096,6 @@ "dashboard.featureCatalogue.dashboardSubtitle": "ダッシュボードでデータを分析します。", "dashboard.featureCatalogue.dashboardTitle": "ダッシュボード", "dashboard.fillDashboardTitle": "このダッシュボードは空です。コンテンツを追加しましょう!", - "dashboard.helpMenu.appName": "ダッシュボード", "dashboard.howToStartWorkingOnNewDashboardDescription": "上のメニューバーで[編集]をクリックすると、パネルの追加を開始します。", "dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel": "ダッシュボードを編集", "dashboard.labs.enableLabsDescription": "このフラグはビューアーで[ラボ]ボタンを使用できるかどうかを決定します。ダッシュボードで実験的機能を有効および無効にするための簡単な方法です。", @@ -1120,7 +1113,6 @@ "dashboard.listing.unsaved.editTitle": "編集を続行", "dashboard.listing.unsaved.loading": "読み込み中", "dashboard.loadURLError.PanelTooOld": "7.3より古いバージョンで作成されたURLからはパネルを読み込めません", - "dashboard.migratedChanges": "一部のパネルは正常に最新バージョンに更新されました。", "dashboard.noMatchRoute.bannerTitleText": "ページが見つかりません", "dashboard.panel.AddToLibrary": "ライブラリに保存", "dashboard.panel.clonedToast": "クローンパネル", @@ -1181,7 +1173,6 @@ "dashboard.topNave.shareConfigDescription": "ダッシュボードを共有します", "dashboard.topNave.viewConfigDescription": "表示専用モードに切り替え", "dashboard.unsavedChangesBadge": "保存されていない変更", - "dashboard.urlWasRemovedInSixZeroWarningMessage": "URL「dashboard/create」は6.0で廃止されました。ブックマークを更新してください。", "data.advancedSettings.autocompleteIgnoreTimerangeText": "このプロパティを無効にすると、現在の時間範囲からではなく、データセットからオートコンプリートの候補を取得します。{learnMoreLink}", "data.advancedSettings.autocompleteValueSuggestionMethodText": "KQL自動入力で値の候補をクエリするために使用される方法。terms_enumを選択すると、Elasticsearch用語enum APIを使用して、自動入力候補のパフォーマンスを改善します。(terms_enumはドキュメントレベルのセキュリティと互換性がありません。) terms_aggを選択すると、Elasticsearch用語アグリゲーションを使用します。{learnMoreLink}", "data.advancedSettings.courier.customRequestPreferenceText": "{setRequestReferenceSetting} が {customSettingValue} に設定されている時に使用される {requestPreferenceLink} です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b1ee1a34a5bc..3247f8392675 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1046,7 +1046,6 @@ "dashboard.listing.unsaved.editAria": "继续编辑 {title}", "dashboard.listing.unsaved.unsavedChangesTitle": "在以下 {dash} 中有未保存更改:", "dashboard.loadingError.dashboardGridErrorMessage": "无法加载仪表板:{message}", - "dashboard.loadingError.errorMessage": "加载保存的仪表板时发生错误:{message}", "dashboard.noMatchRoute.bannerText": "Dashboard 应用程序无法识别此路由:{route}。", "dashboard.panel.addToLibrary.successMessage": "面板 {panelTitle} 已添加到可视化库", "dashboard.panel.unableToMigratePanelDataForSixThreeZeroErrorMessage": "无法迁移用于“6.3.0”向后兼容的面板数据,面板不包含所需字段:{key}", @@ -1069,11 +1068,6 @@ "dashboard.appLeaveConfirmModal.unsavedChangesTitle": "未保存的更改", "dashboard.badge.readOnly.text": "只读", "dashboard.badge.readOnly.tooltip": "无法保存仪表板", - "dashboard.changeViewModeConfirmModal.cancelButtonLabel": "继续编辑", - "dashboard.changeViewModeConfirmModal.confirmButtonLabel": "放弃更改", - "dashboard.changeViewModeConfirmModal.description": "返回到查看模式时,您可以保留或丢弃更改。 您无法恢复丢弃的更改。", - "dashboard.changeViewModeConfirmModal.keepUnsavedChangesButtonLabel": "保留更改", - "dashboard.changeViewModeConfirmModal.leaveEditModeTitle": "您有未保存的更改", "dashboard.cloneModal.cloneDashboardTitleAriaLabel": "克隆仪表板标题", "dashboard.createConfirmModal.cancelButtonLabel": "取消", "dashboard.createConfirmModal.confirmButtonLabel": "重头开始", @@ -1104,7 +1098,6 @@ "dashboard.featureCatalogue.dashboardSubtitle": "在仪表板中分析数据。", "dashboard.featureCatalogue.dashboardTitle": "仪表板", "dashboard.fillDashboardTitle": "此仪表板是空的。让我们来填充它!", - "dashboard.helpMenu.appName": "仪表板", "dashboard.howToStartWorkingOnNewDashboardDescription": "单击上面菜单栏中的“编辑”以开始添加面板。", "dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel": "编辑仪表板", "dashboard.labs.enableLabsDescription": "此标志决定查看者是否有权访问用于在仪表板中快速启用和禁用技术预览功能的“实验”按钮。", @@ -1122,7 +1115,6 @@ "dashboard.listing.unsaved.editTitle": "继续编辑", "dashboard.listing.unsaved.loading": "正在加载", "dashboard.loadURLError.PanelTooOld": "无法通过在早于 7.3 的版本中创建的 URL 加载面板", - "dashboard.migratedChanges": "某些面板已成功更新到最新版本。", "dashboard.noMatchRoute.bannerTitleText": "未找到页面", "dashboard.panel.AddToLibrary": "保存到库", "dashboard.panel.clonedToast": "克隆的面板", @@ -1183,7 +1175,6 @@ "dashboard.topNave.shareConfigDescription": "共享仪表板", "dashboard.topNave.viewConfigDescription": "切换到仅查看模式", "dashboard.unsavedChangesBadge": "未保存的更改", - "dashboard.urlWasRemovedInSixZeroWarningMessage": "6.0 中已移除 url“dashboard/create”。请更新您的书签。", "data.advancedSettings.autocompleteIgnoreTimerangeText": "禁用此属性可从您的完全数据集中获取自动完成建议,而非从当前时间范围。{learnMoreLink}", "data.advancedSettings.autocompleteValueSuggestionMethodText": "用于在 KQL 自动完成中查询值建议的方法。选择 terms_enum 以使用 Elasticsearch 字词枚举 API 改善自动完成建议性能。(请注意,terms_enum 不兼容文档级别安全性。) 选择 terms_agg 以使用 Elasticsearch 字词聚合。{learnMoreLink}", "data.advancedSettings.courier.customRequestPreferenceText": "将“{setRequestReferenceSetting}”设置为“{customSettingValue}”时,将使用“{requestPreferenceLink}”。",