diff --git a/src/plugins/controls/common/constants.ts b/src/plugins/controls/common/constants.ts index e375a7b2315b..d1434d4df2ae 100644 --- a/src/plugins/controls/common/constants.ts +++ b/src/plugins/controls/common/constants.ts @@ -7,11 +7,25 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { ControlGroupChainingSystem } from './control_group'; import { ControlLabelPosition, ControlWidth } from './types'; -export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'medium'; +export const CONTROL_WIDTH_OPTIONS = { SMALL: 'small', MEDIUM: 'medium', LARGE: 'large' } as const; +export const CONTROL_LABEL_POSITION_OPTIONS = { ONE_LINE: 'oneLine', TWO_LINE: 'twoLine' } as const; +export const CONTROL_CHAINING_OPTIONS = { NONE: 'NONE', HIERARCHICAL: 'HIERARCHICAL' } as const; +export const DEFAULT_CONTROL_WIDTH: ControlWidth = CONTROL_WIDTH_OPTIONS.MEDIUM; +export const DEFAULT_CONTROL_LABEL_POSITION: ControlLabelPosition = + CONTROL_LABEL_POSITION_OPTIONS.ONE_LINE; export const DEFAULT_CONTROL_GROW: boolean = true; -export const DEFAULT_CONTROL_LABEL_POSITION: ControlLabelPosition = 'oneLine'; +export const DEFAULT_CONTROL_CHAINING: ControlGroupChainingSystem = + CONTROL_CHAINING_OPTIONS.HIERARCHICAL; +export const DEFAULT_IGNORE_PARENT_SETTINGS = { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, +} as const; +export const DEFAULT_AUTO_APPLY_SELECTIONS = true; export const TIME_SLIDER_CONTROL = 'timeSlider'; export const RANGE_SLIDER_CONTROL = 'rangeSliderControl'; diff --git a/src/plugins/controls/common/control_group/types.ts b/src/plugins/controls/common/control_group/types.ts index eb47d8b13eb7..ff1e4455046b 100644 --- a/src/plugins/controls/common/control_group/types.ts +++ b/src/plugins/controls/common/control_group/types.ts @@ -9,10 +9,12 @@ import { DataViewField } from '@kbn/data-views-plugin/common'; import { ControlLabelPosition, DefaultControlState, ParentIgnoreSettings } from '../types'; +import { CONTROL_CHAINING_OPTIONS } from '../constants'; export const CONTROL_GROUP_TYPE = 'control_group'; -export type ControlGroupChainingSystem = 'HIERARCHICAL' | 'NONE'; +export type ControlGroupChainingSystem = + (typeof CONTROL_CHAINING_OPTIONS)[keyof typeof CONTROL_CHAINING_OPTIONS]; export type FieldFilterPredicate = (f: DataViewField) => boolean; @@ -45,15 +47,11 @@ export interface ControlGroupRuntimeState { - panelsJSON: string; // stringified version of ControlSerializedState - ignoreParentSettingsJSON: string; - // In runtime state, we refer to this property as `labelPosition`; - // to avoid migrations, we will continue to refer to this property as `controlStyle` in the serialized state - controlStyle: ControlLabelPosition; - // In runtime state, we refer to the inverse of this property as `autoApplySelections` - // to avoid migrations, we will continue to refer to this property as `showApplySelections` in the serialized state - showApplySelections?: boolean; + extends Omit { + // In runtime state, we refer to this property as `initialChildControlState`, but in + // the serialized state we transform the state object into an array of state objects + // to make it easier for API consumers to add new controls without specifying a uuid key. + controls: Array; } /** diff --git a/src/plugins/controls/common/index.ts b/src/plugins/controls/common/index.ts index dd9c56778bb6..031d3b348272 100644 --- a/src/plugins/controls/common/index.ts +++ b/src/plugins/controls/common/index.ts @@ -17,9 +17,15 @@ export type { } from './types'; export { + DEFAULT_CONTROL_CHAINING, DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_LABEL_POSITION, DEFAULT_CONTROL_WIDTH, + DEFAULT_IGNORE_PARENT_SETTINGS, + DEFAULT_AUTO_APPLY_SELECTIONS, + CONTROL_WIDTH_OPTIONS, + CONTROL_CHAINING_OPTIONS, + CONTROL_LABEL_POSITION_OPTIONS, OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL, TIME_SLIDER_CONTROL, diff --git a/src/plugins/controls/common/types.ts b/src/plugins/controls/common/types.ts index d3a6261aeb9d..d38ca80cb381 100644 --- a/src/plugins/controls/common/types.ts +++ b/src/plugins/controls/common/types.ts @@ -7,12 +7,16 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export type ControlWidth = 'small' | 'medium' | 'large'; -export type ControlLabelPosition = 'twoLine' | 'oneLine'; +import { SerializableRecord } from '@kbn/utility-types'; +import { CONTROL_LABEL_POSITION_OPTIONS, CONTROL_WIDTH_OPTIONS } from './constants'; + +export type ControlWidth = (typeof CONTROL_WIDTH_OPTIONS)[keyof typeof CONTROL_WIDTH_OPTIONS]; +export type ControlLabelPosition = + (typeof CONTROL_LABEL_POSITION_OPTIONS)[keyof typeof CONTROL_LABEL_POSITION_OPTIONS]; export type TimeSlice = [number, number]; -export interface ParentIgnoreSettings { +export interface ParentIgnoreSettings extends SerializableRecord { ignoreFilters?: boolean; ignoreQuery?: boolean; ignoreTimerange?: boolean; diff --git a/src/plugins/controls/public/control_group/control_group_renderer/control_group_renderer.tsx b/src/plugins/controls/public/control_group/control_group_renderer/control_group_renderer.tsx index 6a50c60c4e59..1a05d4b25e22 100644 --- a/src/plugins/controls/public/control_group/control_group_renderer/control_group_renderer.tsx +++ b/src/plugins/controls/public/control_group/control_group_renderer/control_group_renderer.tsx @@ -19,8 +19,11 @@ import { useSearchApi, type ViewMode as ViewModeType } from '@kbn/presentation-p import type { ControlGroupApi } from '../..'; import { CONTROL_GROUP_TYPE, + DEFAULT_CONTROL_LABEL_POSITION, type ControlGroupRuntimeState, type ControlGroupSerializedState, + DEFAULT_CONTROL_CHAINING, + DEFAULT_AUTO_APPLY_SELECTIONS, } from '../../../common'; import { type ControlGroupStateBuilder, @@ -136,16 +139,19 @@ export const ControlGroupRenderer = ({ ...initialState, editorConfig, }); - const state = { - ...omit(initialState, ['initialChildControlState', 'ignoreParentSettings']), + const state: ControlGroupSerializedState = { + ...omit(initialState, ['initialChildControlState']), editorConfig, - controlStyle: initialState?.labelPosition, - panelsJSON: JSON.stringify(initialState?.initialChildControlState ?? {}), - ignoreParentSettingsJSON: JSON.stringify(initialState?.ignoreParentSettings ?? {}), + autoApplySelections: initialState?.autoApplySelections ?? DEFAULT_AUTO_APPLY_SELECTIONS, + labelPosition: initialState?.labelPosition ?? DEFAULT_CONTROL_LABEL_POSITION, + chainingSystem: initialState?.chainingSystem ?? DEFAULT_CONTROL_CHAINING, + controls: Object.entries(initialState?.initialChildControlState ?? {}).map( + ([controlId, value]) => ({ ...value, id: controlId }) + ), }; if (!cancelled) { - setSerializedState(state as ControlGroupSerializedState); + setSerializedState(state); } })(); return () => { diff --git a/src/plugins/controls/public/control_group/get_control_group_factory.tsx b/src/plugins/controls/public/control_group/get_control_group_factory.tsx index 62af1d1f868a..c8ee296d8a30 100644 --- a/src/plugins/controls/public/control_group/get_control_group_factory.tsx +++ b/src/plugins/controls/public/control_group/get_control_group_factory.tsx @@ -33,7 +33,11 @@ import type { ControlPanelsState, ParentIgnoreSettings, } from '../../common'; -import { CONTROL_GROUP_TYPE, DEFAULT_CONTROL_LABEL_POSITION } from '../../common'; +import { + CONTROL_GROUP_TYPE, + DEFAULT_CONTROL_CHAINING, + DEFAULT_CONTROL_LABEL_POSITION, +} from '../../common'; import { openDataControlEditor } from '../controls/data_controls/open_data_control_editor'; import { coreServices, dataViewsService } from '../services/kibana_services'; import { ControlGroup } from './components/control_group'; @@ -45,8 +49,6 @@ import { initSelectionsManager } from './selections_manager'; import type { ControlGroupApi } from './types'; import { deserializeControlGroup } from './utils/serialization_utils'; -const DEFAULT_CHAINING_SYSTEM = 'HIERARCHICAL'; - export const getControlGroupEmbeddableFactory = () => { const controlGroupEmbeddableFactory: ReactEmbeddableFactory< ControlGroupSerializedState, @@ -85,7 +87,7 @@ export const getControlGroupEmbeddableFactory = () => { }); const dataViews = new BehaviorSubject(undefined); const chainingSystem$ = new BehaviorSubject( - chainingSystem ?? DEFAULT_CHAINING_SYSTEM + chainingSystem ?? DEFAULT_CONTROL_CHAINING ); const ignoreParentSettings$ = new BehaviorSubject( ignoreParentSettings @@ -108,7 +110,7 @@ export const getControlGroupEmbeddableFactory = () => { chainingSystem: [ chainingSystem$, (next: ControlGroupChainingSystem) => chainingSystem$.next(next), - (a, b) => (a ?? DEFAULT_CHAINING_SYSTEM) === (b ?? DEFAULT_CHAINING_SYSTEM), + (a, b) => (a ?? DEFAULT_CONTROL_CHAINING) === (b ?? DEFAULT_CONTROL_CHAINING), ], ignoreParentSettings: [ ignoreParentSettings$, @@ -187,14 +189,14 @@ export const getControlGroupEmbeddableFactory = () => { }); }, serializeState: () => { - const { panelsJSON, references } = controlsManager.serializeControls(); + const { controls, references } = controlsManager.serializeControls(); return { rawState: { chainingSystem: chainingSystem$.getValue(), - controlStyle: labelPosition$.getValue(), - showApplySelections: !autoApplySelections$.getValue(), - ignoreParentSettingsJSON: JSON.stringify(ignoreParentSettings$.getValue()), - panelsJSON, + labelPosition: labelPosition$.getValue(), + autoApplySelections: autoApplySelections$.getValue(), + ignoreParentSettings: ignoreParentSettings$.getValue(), + controls, }, references, }; diff --git a/src/plugins/controls/public/control_group/init_controls_manager.ts b/src/plugins/controls/public/control_group/init_controls_manager.ts index ee020bf1fbd5..935845327131 100644 --- a/src/plugins/controls/public/control_group/init_controls_manager.ts +++ b/src/plugins/controls/public/control_group/init_controls_manager.ts @@ -147,9 +147,8 @@ export function initControlsManager( }, serializeControls: () => { const references: Reference[] = []; - const explicitInputPanels: { - [panelId: string]: ControlPanelState & { explicitInput: object }; - } = {}; + + const controls: Array = []; controlsInOrder$.getValue().forEach(({ id }, index) => { const controlApi = getControlApi(id); @@ -166,18 +165,18 @@ export function initControlsManager( references.push(...controlReferences); } - explicitInputPanels[id] = { + controls.push({ grow, order: index, type: controlApi.type, width, - /** Re-add the `explicitInput` layer on serialize so control group saved object retains shape */ - explicitInput: { id, ...rest }, - }; + /** Re-add the `controlConfig` layer on serialize so control group saved object retains shape */ + controlConfig: { id, ...rest }, + }); }); return { - panelsJSON: JSON.stringify(explicitInputPanels), + controls, references, }; }, diff --git a/src/plugins/controls/public/control_group/utils/initialization_utils.ts b/src/plugins/controls/public/control_group/utils/initialization_utils.ts index ea785d05ac73..a35572387e1e 100644 --- a/src/plugins/controls/public/control_group/utils/initialization_utils.ts +++ b/src/plugins/controls/public/control_group/utils/initialization_utils.ts @@ -7,17 +7,18 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { DEFAULT_CONTROL_LABEL_POSITION, type ControlGroupRuntimeState } from '../../../common'; +import { + type ControlGroupRuntimeState, + DEFAULT_CONTROL_CHAINING, + DEFAULT_CONTROL_LABEL_POSITION, + DEFAULT_AUTO_APPLY_SELECTIONS, + DEFAULT_IGNORE_PARENT_SETTINGS, +} from '../../../common'; export const getDefaultControlGroupRuntimeState = (): ControlGroupRuntimeState => ({ initialChildControlState: {}, labelPosition: DEFAULT_CONTROL_LABEL_POSITION, - chainingSystem: 'HIERARCHICAL', - autoApplySelections: true, - ignoreParentSettings: { - ignoreFilters: false, - ignoreQuery: false, - ignoreTimerange: false, - ignoreValidations: false, - }, + chainingSystem: DEFAULT_CONTROL_CHAINING, + autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS, + ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS, }); diff --git a/src/plugins/controls/public/control_group/utils/serialization_utils.ts b/src/plugins/controls/public/control_group/utils/serialization_utils.ts index ad7dea582750..0a046244b732 100644 --- a/src/plugins/controls/public/control_group/utils/serialization_utils.ts +++ b/src/plugins/controls/public/control_group/utils/serialization_utils.ts @@ -16,37 +16,31 @@ import { parseReferenceName } from '../../controls/data_controls/reference_name_ export const deserializeControlGroup = ( state: SerializedPanelState ): ControlGroupRuntimeState => { - const panels = JSON.parse(state.rawState.panelsJSON); - const ignoreParentSettings = JSON.parse(state.rawState.ignoreParentSettingsJSON); + const { controls } = state.rawState; + const controlsMap = Object.fromEntries(controls.map(({ id, ...rest }) => [id, rest])); /** Inject data view references into each individual control */ const references = state.references ?? []; references.forEach((reference) => { const referenceName = reference.name; const { controlId } = parseReferenceName(referenceName); - if (panels[controlId]) { - panels[controlId].dataViewId = reference.id; + if (controlsMap[controlId]) { + controlsMap[controlId].dataViewId = reference.id; } }); - /** Flatten the state of each panel by removing `explicitInput` */ - const flattenedPanels = Object.keys(panels).reduce((prev, panelId) => { - const currentPanel = panels[panelId]; - const currentPanelExplicitInput = panels[panelId].explicitInput; + /** Flatten the state of each control by removing `controlConfig` */ + const flattenedControls = Object.keys(controlsMap).reduce((prev, controlId) => { + const currentControl = controlsMap[controlId]; + const currentControlExplicitInput = controlsMap[controlId].controlConfig; return { ...prev, - [panelId]: { ...omit(currentPanel, 'explicitInput'), ...currentPanelExplicitInput }, + [controlId]: { ...omit(currentControl, 'controlConfig'), ...currentControlExplicitInput }, }; }, {}); return { - ...omit(state.rawState, ['panelsJSON', 'ignoreParentSettingsJSON']), - initialChildControlState: flattenedPanels, - ignoreParentSettings, - autoApplySelections: - typeof state.rawState.showApplySelections === 'boolean' - ? !state.rawState.showApplySelections - : true, // Rename "showApplySelections" to "autoApplySelections" - labelPosition: state.rawState.controlStyle, // Rename "controlStyle" to "labelPosition" + ...state.rawState, + initialChildControlState: flattenedControls, }; }; diff --git a/src/plugins/controls/server/control_group/control_group_migrations.test.ts b/src/plugins/controls/server/control_group/control_group_migrations.test.ts index 69b19225218e..59643d3aa19c 100644 --- a/src/plugins/controls/server/control_group/control_group_migrations.test.ts +++ b/src/plugins/controls/server/control_group/control_group_migrations.test.ts @@ -18,10 +18,8 @@ import { import { OptionsListControlState } from '../../common/options_list'; import { mockDataControlState, mockOptionsListControlState } from '../mocks'; import { removeHideExcludeAndHideExists } from './control_group_migrations'; -import { - SerializableControlGroupState, - getDefaultControlGroupState, -} from './control_group_persistence'; +import { getDefaultControlGroupState } from './control_group_persistence'; +import type { SerializableControlGroupState } from './types'; describe('migrate control group', () => { const getOptionsListControl = ( diff --git a/src/plugins/controls/server/control_group/control_group_migrations.ts b/src/plugins/controls/server/control_group/control_group_migrations.ts index a3d3d06aadaf..e737441cde71 100644 --- a/src/plugins/controls/server/control_group/control_group_migrations.ts +++ b/src/plugins/controls/server/control_group/control_group_migrations.ts @@ -14,7 +14,7 @@ import { type SerializedControlState, } from '../../common'; import { OptionsListControlState } from '../../common/options_list'; -import { SerializableControlGroupState } from './control_group_persistence'; +import { SerializableControlGroupState } from './types'; export const makeControlOrdersZeroBased = (state: SerializableControlGroupState) => { if ( diff --git a/src/plugins/controls/server/control_group/control_group_persistable_state.ts b/src/plugins/controls/server/control_group/control_group_persistable_state.ts index d59ffb216193..9e880242df12 100644 --- a/src/plugins/controls/server/control_group/control_group_persistable_state.ts +++ b/src/plugins/controls/server/control_group/control_group_persistable_state.ts @@ -20,7 +20,7 @@ import { makeControlOrdersZeroBased, removeHideExcludeAndHideExists, } from './control_group_migrations'; -import type { SerializableControlGroupState } from './control_group_persistence'; +import { SerializableControlGroupState } from './types'; const getPanelStatePrefix = (state: SerializedControlState) => `${state.explicitInput.id}:`; diff --git a/src/plugins/controls/server/control_group/control_group_persistence.ts b/src/plugins/controls/server/control_group/control_group_persistence.ts index e90aa850c6d1..bcf61b3bcc1b 100644 --- a/src/plugins/controls/server/control_group/control_group_persistence.ts +++ b/src/plugins/controls/server/control_group/control_group_persistence.ts @@ -9,37 +9,22 @@ import { SerializableRecord } from '@kbn/utility-types'; +import { ControlGroupSavedObjectState, SerializableControlGroupState } from './types'; import { + DEFAULT_CONTROL_CHAINING, DEFAULT_CONTROL_LABEL_POSITION, - type ControlGroupRuntimeState, - type ControlGroupSerializedState, - type ControlPanelState, - type SerializedControlState, + DEFAULT_IGNORE_PARENT_SETTINGS, + DEFAULT_AUTO_APPLY_SELECTIONS, } from '../../common'; export const getDefaultControlGroupState = (): SerializableControlGroupState => ({ panels: {}, labelPosition: DEFAULT_CONTROL_LABEL_POSITION, - chainingSystem: 'HIERARCHICAL', - autoApplySelections: true, - ignoreParentSettings: { - ignoreFilters: false, - ignoreQuery: false, - ignoreTimerange: false, - ignoreValidations: false, - }, + chainingSystem: DEFAULT_CONTROL_CHAINING, + autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS, + ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS, }); -// using SerializableRecord to force type to be read as serializable -export type SerializableControlGroupState = SerializableRecord & - Omit< - ControlGroupRuntimeState, - 'initialChildControlState' | 'ignoreParentSettings' | 'editorConfig' // editor config is not persisted - > & { - ignoreParentSettings: Record; - panels: Record> | {}; - }; - const safeJSONParse = (jsonString?: string): OutType | undefined => { if (!jsonString && typeof jsonString !== 'string') return; try { @@ -49,22 +34,26 @@ const safeJSONParse = (jsonString?: string): OutType | undefined => { } }; -export const controlGroupSerializedStateToSerializableRuntimeState = ( - serializedState: ControlGroupSerializedState +export const controlGroupSavedObjectStateToSerializableRuntimeState = ( + savedObjectState: ControlGroupSavedObjectState ): SerializableControlGroupState => { const defaultControlGroupInput = getDefaultControlGroupState(); return { - chainingSystem: serializedState?.chainingSystem, - labelPosition: serializedState?.controlStyle ?? defaultControlGroupInput.labelPosition, - autoApplySelections: !serializedState?.showApplySelections, - ignoreParentSettings: safeJSONParse(serializedState?.ignoreParentSettingsJSON) ?? {}, - panels: safeJSONParse(serializedState?.panelsJSON) ?? {}, + chainingSystem: + (savedObjectState?.chainingSystem as SerializableControlGroupState['chainingSystem']) ?? + defaultControlGroupInput.chainingSystem, + labelPosition: + (savedObjectState?.controlStyle as SerializableControlGroupState['labelPosition']) ?? + defaultControlGroupInput.labelPosition, + autoApplySelections: !savedObjectState?.showApplySelections, + ignoreParentSettings: safeJSONParse(savedObjectState?.ignoreParentSettingsJSON) ?? {}, + panels: safeJSONParse(savedObjectState?.panelsJSON) ?? {}, }; }; -export const serializableRuntimeStateToControlGroupSerializedState = ( +export const serializableRuntimeStateToControlGroupSavedObjectState = ( serializable: SerializableRecord // It is safe to treat this as SerializableControlGroupState -): ControlGroupSerializedState => { +): ControlGroupSavedObjectState => { return { controlStyle: serializable.labelPosition as SerializableControlGroupState['labelPosition'], chainingSystem: serializable.chainingSystem as SerializableControlGroupState['chainingSystem'], diff --git a/src/plugins/controls/server/control_group/control_group_telemetry.test.ts b/src/plugins/controls/server/control_group/control_group_telemetry.test.ts index da2800a7d744..3647a23d36a1 100644 --- a/src/plugins/controls/server/control_group/control_group_telemetry.test.ts +++ b/src/plugins/controls/server/control_group/control_group_telemetry.test.ts @@ -7,16 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SerializableRecord } from '@kbn/utility-types'; -import { type ControlGroupSerializedState } from '../../common'; -import { - type ControlGroupTelemetry, - controlGroupTelemetry, - initializeControlGroupTelemetry, -} from './control_group_telemetry'; +import { controlGroupTelemetry, initializeControlGroupTelemetry } from './control_group_telemetry'; +import { ControlGroupSavedObjectState, ControlGroupTelemetry } from './types'; // controls attributes with all settings ignored + 3 options lists + hierarchical chaining + label above -const rawControlAttributes1: SerializableRecord & ControlGroupSerializedState = { +const rawControlAttributes1: ControlGroupSavedObjectState = { controlStyle: 'twoLine', chainingSystem: 'NONE', showApplySelections: true, @@ -27,7 +22,7 @@ const rawControlAttributes1: SerializableRecord & ControlGroupSerializedState = }; // controls attributes with some settings ignored + 2 range sliders, 1 time slider + No chaining + label inline -const rawControlAttributes2: SerializableRecord & ControlGroupSerializedState = { +const rawControlAttributes2: ControlGroupSavedObjectState = { controlStyle: 'oneLine', chainingSystem: 'NONE', showApplySelections: false, @@ -38,7 +33,7 @@ const rawControlAttributes2: SerializableRecord & ControlGroupSerializedState = }; // controls attributes with no settings ignored + 2 options lists, 1 range slider, 1 time slider + hierarchical chaining + label inline -const rawControlAttributes3: SerializableRecord & ControlGroupSerializedState = { +const rawControlAttributes3: ControlGroupSavedObjectState = { controlStyle: 'oneLine', chainingSystem: 'HIERARCHICAL', showApplySelections: false, diff --git a/src/plugins/controls/server/control_group/control_group_telemetry.ts b/src/plugins/controls/server/control_group/control_group_telemetry.ts index 21d1baf40116..72944202b955 100644 --- a/src/plugins/controls/server/control_group/control_group_telemetry.ts +++ b/src/plugins/controls/server/control_group/control_group_telemetry.ts @@ -9,31 +9,15 @@ import { PersistableStateService } from '@kbn/kibana-utils-plugin/common'; import { set } from '@kbn/safer-lodash-set'; -import type { ControlGroupSerializedState } from '../../common'; import { - type SerializableControlGroupState, - controlGroupSerializedStateToSerializableRuntimeState, + controlGroupSavedObjectStateToSerializableRuntimeState, getDefaultControlGroupState, } from './control_group_persistence'; - -export interface ControlGroupTelemetry { - total: number; - chaining_system: { - [key: string]: number; - }; - label_position: { - [key: string]: number; - }; - ignore_settings: { - [key: string]: number; - }; - by_type: { - [key: string]: { - total: number; - details: { [key: string]: number }; - }; - }; -} +import { + ControlGroupSavedObjectState, + ControlGroupTelemetry, + SerializableControlGroupState, +} from './types'; export const initializeControlGroupTelemetry = ( statsSoFar: Record @@ -113,8 +97,8 @@ export const controlGroupTelemetry: PersistableStateService['telemetry'] = ( const controlGroupStats = initializeControlGroupTelemetry(stats); const controlGroupState = { ...getDefaultControlGroupState(), - ...controlGroupSerializedStateToSerializableRuntimeState( - state as unknown as ControlGroupSerializedState + ...controlGroupSavedObjectStateToSerializableRuntimeState( + state as unknown as ControlGroupSavedObjectState ), }; if (!controlGroupState) return controlGroupStats; diff --git a/src/plugins/controls/server/control_group/types.ts b/src/plugins/controls/server/control_group/types.ts new file mode 100644 index 000000000000..9aa0aaddc4a1 --- /dev/null +++ b/src/plugins/controls/server/control_group/types.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { SerializableRecord } from '@kbn/utility-types'; +import { ControlGroupRuntimeState, ControlPanelState, SerializedControlState } from '../../common'; + +// using SerializableRecord to force type to be read as serializable +export type SerializableControlGroupState = SerializableRecord & + Omit< + ControlGroupRuntimeState, + 'initialChildControlState' | 'editorConfig' // editor config is not persisted + > & { + panels: Record> | {}; + }; + +export type ControlGroupSavedObjectState = SerializableRecord & { + chainingSystem: SerializableControlGroupState['chainingSystem']; + controlStyle: SerializableControlGroupState['labelPosition']; + showApplySelections: boolean; + ignoreParentSettingsJSON: string; + panelsJSON: string; +}; + +export interface ControlGroupTelemetry { + total: number; + chaining_system: { + [key: string]: number; + }; + label_position: { + [key: string]: number; + }; + ignore_settings: { + [key: string]: number; + }; + by_type: { + [key: string]: { + total: number; + details: { [key: string]: number }; + }; + }; +} diff --git a/src/plugins/controls/server/index.ts b/src/plugins/controls/server/index.ts index 541d9e2a4620..40261f8a3013 100644 --- a/src/plugins/controls/server/index.ts +++ b/src/plugins/controls/server/index.ts @@ -13,10 +13,9 @@ export const plugin = async () => { }; export { - controlGroupSerializedStateToSerializableRuntimeState, - serializableRuntimeStateToControlGroupSerializedState, + controlGroupSavedObjectStateToSerializableRuntimeState, + serializableRuntimeStateToControlGroupSavedObjectState, } from './control_group/control_group_persistence'; -export { - type ControlGroupTelemetry, - initializeControlGroupTelemetry, -} from './control_group/control_group_telemetry'; +export { initializeControlGroupTelemetry } from './control_group/control_group_telemetry'; + +export type { ControlGroupTelemetry } from './control_group/types'; diff --git a/src/plugins/controls/tsconfig.json b/src/plugins/controls/tsconfig.json index e1040faecc1b..41ab33dc1896 100644 --- a/src/plugins/controls/tsconfig.json +++ b/src/plugins/controls/tsconfig.json @@ -38,7 +38,7 @@ "@kbn/field-formats-plugin", "@kbn/presentation-panel-plugin", "@kbn/shared-ux-utility", - "@kbn/std" + "@kbn/std", ], "exclude": ["target/**/*"] } diff --git a/src/plugins/dashboard/common/bwc/types.ts b/src/plugins/dashboard/common/bwc/types.ts index b1b97fa31485..ae409d143656 100644 --- a/src/plugins/dashboard/common/bwc/types.ts +++ b/src/plugins/dashboard/common/bwc/types.ts @@ -9,7 +9,7 @@ import type { SavedObjectReference } from '@kbn/core/public'; import type { Serializable } from '@kbn/utility-types'; -import { GridData } from '../content_management'; +import type { GridData } from '../../server/dashboard_saved_object'; interface KibanaAttributes { kibanaSavedObjectMeta: { diff --git a/src/plugins/dashboard/common/content_management/constants.ts b/src/plugins/dashboard/common/content_management/constants.ts index 29c679872a9e..978271680af1 100644 --- a/src/plugins/dashboard/common/content_management/constants.ts +++ b/src/plugins/dashboard/common/content_management/constants.ts @@ -7,6 +7,18 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export const LATEST_VERSION = 2; +export const LATEST_VERSION = 3; export const CONTENT_ID = 'dashboard'; + +export const DASHBOARD_GRID_COLUMN_COUNT = 48; +export const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2; +export const DEFAULT_PANEL_HEIGHT = 15; + +export const DEFAULT_DASHBOARD_OPTIONS = { + hidePanelTitles: false, + useMargins: true, + syncColors: true, + syncCursor: true, + syncTooltips: true, +} as const; diff --git a/src/plugins/dashboard/common/content_management/index.ts b/src/plugins/dashboard/common/content_management/index.ts index d87d65a61d4f..b87b54520d7a 100644 --- a/src/plugins/dashboard/common/content_management/index.ts +++ b/src/plugins/dashboard/common/content_management/index.ts @@ -7,14 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { LATEST_VERSION, CONTENT_ID } from './constants'; +export { + LATEST_VERSION, + CONTENT_ID, + DASHBOARD_GRID_COLUMN_COUNT, + DEFAULT_PANEL_HEIGHT, + DEFAULT_PANEL_WIDTH, + DEFAULT_DASHBOARD_OPTIONS, +} from './constants'; export type { DashboardContentType } from './types'; - -export type { - GridData, - DashboardItem, - DashboardCrudTypes, - DashboardAttributes, - SavedDashboardPanel, -} from './latest'; diff --git a/src/plugins/dashboard/common/content_management/v1/types.ts b/src/plugins/dashboard/common/content_management/v1/types.ts index 9b7c2973d971..3b3317c0bd13 100644 --- a/src/plugins/dashboard/common/content_management/v1/types.ts +++ b/src/plugins/dashboard/common/content_management/v1/types.ts @@ -14,7 +14,7 @@ import type { } from '@kbn/content-management-utils'; import { Serializable } from '@kbn/utility-types'; import { RefreshInterval } from '@kbn/data-plugin/common'; -import { ControlGroupSerializedState } from '@kbn/controls-plugin/common'; +import { ControlGroupChainingSystem, ControlLabelPosition } from '@kbn/controls-plugin/common'; import { DashboardContentType } from '../types'; @@ -62,10 +62,13 @@ export interface SavedDashboardPanel { version?: string; } -type ControlGroupAttributesV1 = Pick< - ControlGroupSerializedState, - 'panelsJSON' | 'chainingSystem' | 'controlStyle' | 'ignoreParentSettingsJSON' ->; +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ControlGroupAttributesV1 = { + chainingSystem?: ControlGroupChainingSystem; + panelsJSON: string; // stringified version of ControlSerializedState + ignoreParentSettingsJSON: string; + controlStyle?: ControlLabelPosition; +}; /* eslint-disable-next-line @typescript-eslint/consistent-type-definitions */ export type DashboardAttributes = { @@ -77,7 +80,7 @@ export type DashboardAttributes = { description: string; panelsJSON: string; timeFrom?: string; - version: number; + version?: number; timeTo?: string; title: string; kibanaSavedObjectMeta: { diff --git a/src/plugins/dashboard/common/content_management/v2/index.ts b/src/plugins/dashboard/common/content_management/v2/index.ts index b0b10669699c..bd687ff0dd60 100644 --- a/src/plugins/dashboard/common/content_management/v2/index.ts +++ b/src/plugins/dashboard/common/content_management/v2/index.ts @@ -8,4 +8,4 @@ */ export type { GridData, DashboardItem, SavedDashboardPanel } from '../v1/types'; // no changes made to types from v1 to v2 -export type { DashboardCrudTypes, DashboardAttributes } from './types'; +export type { ControlGroupAttributes, DashboardCrudTypes, DashboardAttributes } from './types'; diff --git a/src/plugins/dashboard/common/content_management/v2/types.ts b/src/plugins/dashboard/common/content_management/v2/types.ts index 3f009b749a2a..ae2c2a798d81 100644 --- a/src/plugins/dashboard/common/content_management/v2/types.ts +++ b/src/plugins/dashboard/common/content_management/v2/types.ts @@ -12,21 +12,18 @@ import type { SavedObjectCreateOptions, SavedObjectUpdateOptions, } from '@kbn/content-management-utils'; -import { ControlGroupSerializedState } from '@kbn/controls-plugin/common'; import { DashboardContentType } from '../types'; -import { DashboardAttributes as DashboardAttributesV1 } from '../v1/types'; +import { + ControlGroupAttributesV1, + DashboardAttributes as DashboardAttributesV1, +} from '../v1/types'; -type ControlGroupAttributesV2 = Pick< - ControlGroupSerializedState, - | 'panelsJSON' - | 'chainingSystem' - | 'controlStyle' - | 'ignoreParentSettingsJSON' - | 'showApplySelections' ->; +export type ControlGroupAttributes = ControlGroupAttributesV1 & { + showApplySelections?: boolean; +}; export type DashboardAttributes = Omit & { - controlGroupInput?: ControlGroupAttributesV2; + controlGroupInput?: ControlGroupAttributes; }; export type DashboardCrudTypes = ContentManagementCrudTypes< diff --git a/src/plugins/dashboard/common/dashboard_container/types.ts b/src/plugins/dashboard/common/dashboard_container/types.ts index bcb7670f18e1..dd3f7302038c 100644 --- a/src/plugins/dashboard/common/dashboard_container/types.ts +++ b/src/plugins/dashboard/common/dashboard_container/types.ts @@ -18,8 +18,7 @@ import type { Reference } from '@kbn/content-management-utils'; import { RefreshInterval } from '@kbn/data-plugin/common'; import { KibanaExecutionContext } from '@kbn/core-execution-context-common'; -import { DashboardOptions } from '../types'; -import { GridData } from '../content_management'; +import type { DashboardOptions, GridData } from '../../server/content_management'; export interface DashboardPanelMap { [key: string]: DashboardPanelState; diff --git a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts index 689db61b0cb2..e9bd6aff0fe1 100644 --- a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts +++ b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.test.ts @@ -18,7 +18,8 @@ import { createInject, } from '../../dashboard_container/persistable_state/dashboard_container_references'; import { createEmbeddablePersistableStateServiceMock } from '@kbn/embeddable-plugin/common/mocks'; -import { DashboardAttributes } from '../../content_management'; +import type { DashboardAttributes, DashboardItem } from '../../../server/content_management'; +import { DashboardAttributesAndReferences } from '../../types'; const embeddablePersistableStateServiceMock = createEmbeddablePersistableStateServiceMock(); const dashboardInject = createInject(embeddablePersistableStateServiceMock); @@ -44,28 +45,37 @@ const deps: InjectExtractDeps = { }; const commonAttributes: DashboardAttributes = { - kibanaSavedObjectMeta: { searchSourceJSON: '' }, + kibanaSavedObjectMeta: { searchSource: {} }, timeRestore: false, - panelsJSON: '', version: 1, + options: { + hidePanelTitles: false, + useMargins: true, + syncColors: true, + syncCursor: true, + syncTooltips: true, + }, + panels: [], description: '', title: '', }; describe('extractReferences', () => { - test('extracts references from panelsJSON', () => { + test('extracts references from panels', () => { const doc = { id: '1', attributes: { ...commonAttributes, foo: true, - panelsJSON: JSON.stringify([ + panels: [ { panelIndex: 'panel-1', type: 'visualization', id: '1', title: 'Title 1', version: '7.9.1', + gridData: { x: 0, y: 0, w: 1, h: 1, i: 'panel-1' }, + panelConfig: {}, }, { panelIndex: 'panel-2', @@ -73,8 +83,10 @@ describe('extractReferences', () => { id: '2', title: 'Title 2', version: '7.9.1', + gridData: { x: 1, y: 1, w: 2, h: 2, i: 'panel-2' }, + panelConfig: {}, }, - ]), + ], }, references: [], }; @@ -86,9 +98,47 @@ describe('extractReferences', () => { "description": "", "foo": true, "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", + "searchSource": Object {}, + }, + "options": Object { + "hidePanelTitles": false, + "syncColors": true, + "syncCursor": true, + "syncTooltips": true, + "useMargins": true, }, - "panelsJSON": "[{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"panelIndex\\":\\"panel-1\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"panelRefName\\":\\"panel_panel-1\\"},{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"panelIndex\\":\\"panel-2\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"panelRefName\\":\\"panel_panel-2\\"}]", + "panels": Array [ + Object { + "gridData": Object { + "h": 1, + "i": "panel-1", + "w": 1, + "x": 0, + "y": 0, + }, + "panelConfig": Object {}, + "panelIndex": "panel-1", + "panelRefName": "panel_panel-1", + "title": "Title 1", + "type": "visualization", + "version": "7.9.1", + }, + Object { + "gridData": Object { + "h": 2, + "i": "panel-2", + "w": 2, + "x": 1, + "y": 1, + }, + "panelConfig": Object {}, + "panelIndex": "panel-2", + "panelRefName": "panel_panel-2", + "title": "Title 2", + "type": "visualization", + "version": "7.9.1", + }, + ], "timeRestore": false, "title": "", "version": 1, @@ -115,18 +165,18 @@ describe('extractReferences', () => { attributes: { ...commonAttributes, foo: true, - panelsJSON: JSON.stringify([ + panels: [ { id: '1', title: 'Title 1', version: '7.9.1', }, - ]), + ], }, references: [], - }; + } as unknown as DashboardAttributesAndReferences; expect(() => extractReferences(doc, deps)).toThrowErrorMatchingInlineSnapshot( - `"\\"type\\" attribute is missing from panel \\"undefined\\""` + `"\\"type\\" attribute is missing from panel \\"0\\""` ); }); @@ -136,25 +186,49 @@ describe('extractReferences', () => { attributes: { ...commonAttributes, foo: true, - panelsJSON: JSON.stringify([ + panels: [ { type: 'visualization', title: 'Title 1', version: '7.9.1', + gridData: { x: 0, y: 0, w: 1, h: 1, i: 'panel-1' }, + panelConfig: {}, }, - ]), + ], }, references: [], }; - expect(extractReferences(doc, deps)).toMatchInlineSnapshot(` + expect(extractReferences(doc as unknown as DashboardItem, deps)).toMatchInlineSnapshot(` Object { "attributes": Object { "description": "", "foo": true, "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", + "searchSource": Object {}, + }, + "options": Object { + "hidePanelTitles": false, + "syncColors": true, + "syncCursor": true, + "syncTooltips": true, + "useMargins": true, }, - "panelsJSON": "[{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\"}]", + "panels": Array [ + Object { + "gridData": Object { + "h": 1, + "i": "panel-1", + "w": 1, + "x": 0, + "y": 0, + }, + "panelConfig": Object {}, + "panelIndex": "0", + "title": "Title 1", + "type": "visualization", + "version": "7.9.1", + }, + ], "timeRestore": false, "title": "", "version": 1, @@ -171,18 +245,26 @@ describe('injectReferences', () => { ...commonAttributes, id: '1', title: 'test', - panelsJSON: JSON.stringify([ + panels: [ { + type: 'visualization', panelRefName: 'panel_0', + panelIndex: '0', title: 'Title 1', version: '7.9.0', + gridData: { x: 0, y: 0, w: 1, h: 1, i: '0' }, + panelConfig: {}, }, { + type: 'visualization', panelRefName: 'panel_1', + panelIndex: '1', title: 'Title 2', version: '7.9.0', + gridData: { x: 1, y: 1, w: 2, h: 2, i: '1' }, + panelConfig: {}, }, - ]), + ], }; const references = [ { @@ -203,9 +285,47 @@ describe('injectReferences', () => { "description": "", "id": "1", "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", + "searchSource": Object {}, }, - "panelsJSON": "[{\\"version\\":\\"7.9.0\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"version\\":\\"7.9.0\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"id\\":\\"2\\"}]", + "options": Object { + "hidePanelTitles": false, + "syncColors": true, + "syncCursor": true, + "syncTooltips": true, + "useMargins": true, + }, + "panels": Array [ + Object { + "gridData": Object { + "h": 1, + "i": "0", + "w": 1, + "x": 0, + "y": 0, + }, + "id": "1", + "panelConfig": Object {}, + "panelIndex": "0", + "title": "Title 1", + "type": "visualization", + "version": "7.9.0", + }, + Object { + "gridData": Object { + "h": 2, + "i": "1", + "w": 2, + "x": 1, + "y": 1, + }, + "id": "2", + "panelConfig": Object {}, + "panelIndex": "1", + "title": "Title 2", + "type": "visualization", + "version": "7.9.0", + }, + ], "timeRestore": false, "title": "test", "version": 1, @@ -213,7 +333,7 @@ describe('injectReferences', () => { `); }); - test('skips when panelsJSON is missing', () => { + test('skips when panels is missing', () => { const attributes = { id: '1', title: 'test', @@ -222,31 +342,8 @@ describe('injectReferences', () => { expect(newAttributes).toMatchInlineSnapshot(` Object { "id": "1", - "panelsJSON": "[]", - "title": "test", - } - `); - }); - - test('skips when panelsJSON is not an array', () => { - const attributes = { - ...commonAttributes, - id: '1', - panelsJSON: '{}', - title: 'test', - }; - const newAttributes = injectReferences({ attributes, references: [] }, deps); - expect(newAttributes).toMatchInlineSnapshot(` - Object { - "description": "", - "id": "1", - "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", - }, - "panelsJSON": "[]", - "timeRestore": false, + "panels": Array [], "title": "test", - "version": 1, } `); }); @@ -256,15 +353,23 @@ describe('injectReferences', () => { ...commonAttributes, id: '1', title: 'test', - panelsJSON: JSON.stringify([ + panels: [ { + type: 'visualization', panelRefName: 'panel_0', + panelIndex: '0', title: 'Title 1', + gridData: { x: 0, y: 0, w: 1, h: 1, i: '0' }, + panelConfig: {}, }, { + type: 'visualization', + panelIndex: '1', title: 'Title 2', + gridData: { x: 1, y: 1, w: 2, h: 2, i: '1' }, + panelConfig: {}, }, - ]), + ], }; const references = [ { @@ -279,9 +384,46 @@ describe('injectReferences', () => { "description": "", "id": "1", "kibanaSavedObjectMeta": Object { - "searchSourceJSON": "", + "searchSource": Object {}, + }, + "options": Object { + "hidePanelTitles": false, + "syncColors": true, + "syncCursor": true, + "syncTooltips": true, + "useMargins": true, }, - "panelsJSON": "[{\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\"}]", + "panels": Array [ + Object { + "gridData": Object { + "h": 1, + "i": "0", + "w": 1, + "x": 0, + "y": 0, + }, + "id": "1", + "panelConfig": Object {}, + "panelIndex": "0", + "title": "Title 1", + "type": "visualization", + "version": undefined, + }, + Object { + "gridData": Object { + "h": 2, + "i": "1", + "w": 2, + "x": 1, + "y": 1, + }, + "panelConfig": Object {}, + "panelIndex": "1", + "title": "Title 2", + "type": "visualization", + "version": undefined, + }, + ], "timeRestore": false, "title": "test", "version": 1, @@ -294,12 +436,16 @@ describe('injectReferences', () => { ...commonAttributes, id: '1', title: 'test', - panelsJSON: JSON.stringify([ + panels: [ { + panelIndex: '0', panelRefName: 'panel_0', title: 'Title 1', + type: 'visualization', + gridData: { x: 0, y: 0, w: 1, h: 1, i: '0' }, + panelConfig: {}, }, - ]), + ], }; expect(() => injectReferences({ attributes, references: [] }, deps) diff --git a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts index 1ede56a2b67a..9b9290accb51 100644 --- a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts +++ b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts @@ -11,11 +11,11 @@ import type { Reference } from '@kbn/content-management-utils'; import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common/types'; import { - convertPanelMapToSavedPanels, - convertSavedPanelsToPanelMap, + convertPanelMapToPanelsArray, + convertPanelsArrayToPanelMap, } from '../../lib/dashboard_panel_converters'; import { DashboardAttributesAndReferences, ParsedDashboardAttributesWithType } from '../../types'; -import { DashboardAttributes, SavedDashboardPanel } from '../../content_management'; +import type { DashboardAttributes } from '../../../server/content_management'; import { createExtract, createInject, @@ -25,20 +25,12 @@ export interface InjectExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; } -function parseDashboardAttributesWithType( - attributes: DashboardAttributes -): ParsedDashboardAttributesWithType { - let parsedPanels = [] as SavedDashboardPanel[]; - if (typeof attributes.panelsJSON === 'string') { - const parsedJSON = JSON.parse(attributes.panelsJSON); - if (Array.isArray(parsedJSON)) { - parsedPanels = parsedJSON as SavedDashboardPanel[]; - } - } - +function parseDashboardAttributesWithType({ + panels, +}: DashboardAttributes): ParsedDashboardAttributesWithType { return { type: 'dashboard', - panels: convertSavedPanelsToPanelMap(parsedPanels), + panels: convertPanelsArrayToPanelMap(panels), } as ParsedDashboardAttributesWithType; } @@ -51,12 +43,12 @@ export function injectReferences( // inject references back into panels via the Embeddable persistable state service. const inject = createInject(deps.embeddablePersistableStateService); const injectedState = inject(parsedAttributes, references) as ParsedDashboardAttributesWithType; - const injectedPanels = convertPanelMapToSavedPanels(injectedState.panels); + const injectedPanels = convertPanelMapToPanelsArray(injectedState.panels); const newAttributes = { ...attributes, - panelsJSON: JSON.stringify(injectedPanels), - } as DashboardAttributes; + panels: injectedPanels, + }; return newAttributes; } @@ -81,12 +73,12 @@ export function extractReferences( references: Reference[]; state: ParsedDashboardAttributesWithType; }; - const extractedPanels = convertPanelMapToSavedPanels(extractedState.panels); + const extractedPanels = convertPanelMapToPanelsArray(extractedState.panels); const newAttributes = { ...attributes, - panelsJSON: JSON.stringify(extractedPanels), - } as DashboardAttributes; + panels: extractedPanels, + }; return { references: [...references, ...extractedReferences], diff --git a/src/plugins/dashboard/common/index.ts b/src/plugins/dashboard/common/index.ts index 8de0c49c41ee..be2cedf889e8 100644 --- a/src/plugins/dashboard/common/index.ts +++ b/src/plugins/dashboard/common/index.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export type { DashboardOptions, DashboardCapabilities, SharedDashboardState } from './types'; +export type { DashboardCapabilities, SharedDashboardState } from './types'; export type { DashboardPanelMap, @@ -16,9 +16,8 @@ export type { DashboardContainerByReferenceInput, } from './dashboard_container/types'; -export type { DashboardAttributes, SavedDashboardPanel } from './content_management'; - export { + type InjectExtractDeps, injectReferences, extractReferences, } from './dashboard_saved_object/persistable_state/dashboard_saved_object_references'; @@ -31,10 +30,8 @@ export { export { prefixReferencesFromPanel } from './dashboard_container/persistable_state/dashboard_container_references'; export { - convertPanelStateToSavedDashboardPanel, - convertSavedDashboardPanelToPanelState, - convertSavedPanelsToPanelMap, - convertPanelMapToSavedPanels, + convertPanelsArrayToPanelMap, + convertPanelMapToPanelsArray, } from './lib/dashboard_panel_converters'; export const UI_SETTINGS = { diff --git a/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts b/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts index a8c2d1f7c7b8..67317083b445 100644 --- a/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts +++ b/src/plugins/dashboard/common/lib/dashboard_panel_converters.ts @@ -9,77 +9,63 @@ import { v4 } from 'uuid'; import { omit } from 'lodash'; -import { EmbeddableInput, SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common'; +import type { SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common'; import type { Reference } from '@kbn/content-management-utils'; -import { DashboardPanelMap, DashboardPanelState } from '..'; -import { SavedDashboardPanel } from '../content_management'; +import type { DashboardPanelMap } from '..'; +import type { DashboardPanel } from '../../server/content_management'; + import { getReferencesForPanelId, prefixReferencesFromPanel, } from '../dashboard_container/persistable_state/dashboard_container_references'; -export function convertSavedDashboardPanelToPanelState< - TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput ->(savedDashboardPanel: SavedDashboardPanel): DashboardPanelState { - return { - type: savedDashboardPanel.type, - gridData: savedDashboardPanel.gridData, - panelRefName: savedDashboardPanel.panelRefName, - explicitInput: { - id: savedDashboardPanel.panelIndex, - ...(savedDashboardPanel.id !== undefined && { savedObjectId: savedDashboardPanel.id }), - ...(savedDashboardPanel.title !== undefined && { title: savedDashboardPanel.title }), - ...savedDashboardPanel.embeddableConfig, - } as TEmbeddableInput, - - /** - * Version information used to be stored in the panel until 8.11 when it was moved - * to live inside the explicit Embeddable Input. If version information is given here, we'd like to keep it. - * It will be removed on Dashboard save - */ - version: savedDashboardPanel.version, - }; -} - -export function convertPanelStateToSavedDashboardPanel( - panelState: DashboardPanelState, - removeLegacyVersion?: boolean -): SavedDashboardPanel { - const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId; - return { - /** - * Version information used to be stored in the panel until 8.11 when it was moved to live inside the - * explicit Embeddable Input. If removeLegacyVersion is not passed, we'd like to keep this information for - * the time being. - */ - ...(!removeLegacyVersion ? { version: panelState.version } : {}), - - type: panelState.type, - gridData: panelState.gridData, - panelIndex: panelState.explicitInput.id, - embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']), - ...(panelState.explicitInput.title !== undefined && { title: panelState.explicitInput.title }), - ...(savedObjectId !== undefined && { id: savedObjectId }), - ...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }), - }; -} - -export const convertSavedPanelsToPanelMap = (panels?: SavedDashboardPanel[]): DashboardPanelMap => { +export const convertPanelsArrayToPanelMap = (panels?: DashboardPanel[]): DashboardPanelMap => { const panelsMap: DashboardPanelMap = {}; panels?.forEach((panel, idx) => { - panelsMap![panel.panelIndex ?? String(idx)] = convertSavedDashboardPanelToPanelState(panel); + const panelIndex = panel.panelIndex ?? String(idx); + panelsMap![panel.panelIndex ?? String(idx)] = { + type: panel.type, + gridData: panel.gridData, + panelRefName: panel.panelRefName, + explicitInput: { + id: panelIndex, + ...(panel.id !== undefined && { savedObjectId: panel.id }), + ...(panel.title !== undefined && { title: panel.title }), + ...panel.panelConfig, + }, + version: panel.version, + }; }); return panelsMap; }; -export const convertPanelMapToSavedPanels = ( +export const convertPanelMapToPanelsArray = ( panels: DashboardPanelMap, removeLegacyVersion?: boolean ) => { - return Object.values(panels).map((panel) => - convertPanelStateToSavedDashboardPanel(panel, removeLegacyVersion) - ); + return Object.values(panels).map((panelState) => { + const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId; + const panelIndex = panelState.explicitInput.id; + return { + /** + * Version information used to be stored in the panel until 8.11 when it was moved to live inside the + * explicit Embeddable Input. If removeLegacyVersion is not passed, we'd like to keep this information for + * the time being. + */ + ...(!removeLegacyVersion ? { version: panelState.version } : {}), + + type: panelState.type, + gridData: panelState.gridData, + panelIndex, + panelConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']), + ...(panelState.explicitInput.title !== undefined && { + title: panelState.explicitInput.title, + }), + ...(savedObjectId !== undefined && { id: savedObjectId }), + ...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }), + }; + }); }; /** diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index b3b4b1e983b2..c8ecc237ed34 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -8,17 +8,9 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; -import { DashboardAttributes, SavedDashboardPanel } from './content_management'; -import { DashboardContainerInput, DashboardPanelMap } from './dashboard_container/types'; - -export interface DashboardOptions { - hidePanelTitles: boolean; - useMargins: boolean; - syncColors: boolean; - syncTooltips: boolean; - syncCursor: boolean; -} +import type { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; +import type { DashboardContainerInput, DashboardPanelMap } from './dashboard_container/types'; +import type { DashboardAttributes, DashboardPanel } from '../server/content_management'; export interface DashboardCapabilities { showWriteControls: boolean; @@ -32,7 +24,7 @@ export interface DashboardCapabilities { * For BWC reasons, dashboard state is stored with panels as an array instead of a map */ export type SharedDashboardState = Partial< - Omit & { panels: SavedDashboardPanel[] } + Omit & { panels: DashboardPanel[] } >; /** diff --git a/src/plugins/dashboard/public/dashboard_app/locator/load_dashboard_history_location_state.ts b/src/plugins/dashboard/public/dashboard_app/locator/load_dashboard_history_location_state.ts index 73832625cf11..99aa14fe6225 100644 --- a/src/plugins/dashboard/public/dashboard_app/locator/load_dashboard_history_location_state.ts +++ b/src/plugins/dashboard/public/dashboard_app/locator/load_dashboard_history_location_state.ts @@ -10,7 +10,7 @@ import { ScopedHistory } from '@kbn/core-application-browser'; import { ForwardedDashboardState } from './locator'; -import { convertSavedPanelsToPanelMap, DashboardContainerInput } from '../../../common'; +import { convertPanelsArrayToPanelMap, DashboardContainerInput } from '../../../common'; export const loadDashboardHistoryLocationState = ( getScopedHistory: () => ScopedHistory @@ -28,6 +28,6 @@ export const loadDashboardHistoryLocationState = ( return { ...restOfState, - ...{ panels: convertSavedPanelsToPanelMap(panels) }, + ...{ panels: convertPanelsArrayToPanelMap(panels) }, }; }; diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx index 41c4a55f6ab8..de7a1584dc9b 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx @@ -8,7 +8,7 @@ */ import { Capabilities } from '@kbn/core/public'; -import { convertPanelMapToSavedPanels, DashboardContainerInput } from '../../../../common'; +import { convertPanelMapToPanelsArray, DashboardContainerInput } from '../../../../common'; import { DashboardLocatorParams } from '../../../dashboard_container'; import { shareService } from '../../../services/kibana_services'; @@ -143,7 +143,7 @@ describe('ShowShareModal', () => { ).locatorParams.params; const rawDashboardState = { ...unsavedDashboardState, - panels: convertPanelMapToSavedPanels(unsavedDashboardState.panels), + panels: convertPanelMapToPanelsArray(unsavedDashboardState.panels), }; unsavedStateKeys.forEach((key) => { expect(shareLocatorParams[key]).toStrictEqual( @@ -208,8 +208,8 @@ describe('ShowShareModal', () => { ).locatorParams.params; expect(shareLocatorParams.panels).toBeDefined(); - expect(shareLocatorParams.panels![0].embeddableConfig.changedKey1).toBe('changed'); - expect(shareLocatorParams.panels![1].embeddableConfig.changedKey2).toBe('definitely changed'); - expect(shareLocatorParams.panels![2].embeddableConfig.changedKey3).toBe('should still exist'); + expect(shareLocatorParams.panels![0].panelConfig.changedKey1).toBe('changed'); + expect(shareLocatorParams.panels![1].panelConfig.changedKey2).toBe('definitely changed'); + expect(shareLocatorParams.panels![2].panelConfig.changedKey3).toBe('should still exist'); }); }); diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx index 5dd56465de92..2e3690e40d4e 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx @@ -19,7 +19,7 @@ import { ViewMode } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; import { getStateFromKbnUrl, setStateToKbnUrl, unhashUrl } from '@kbn/kibana-utils-plugin/public'; -import { convertPanelMapToSavedPanels, DashboardPanelMap } from '../../../../common'; +import { convertPanelMapToPanelsArray, DashboardPanelMap } from '../../../../common'; import { DashboardLocatorParams } from '../../../dashboard_container'; import { getDashboardBackupService, @@ -151,7 +151,7 @@ export function ShowShareModal({ ...latestPanels, ...modifiedPanels, }; - return convertPanelMapToSavedPanels(allUnsavedPanelsMap); + return convertPanelMapToPanelsArray(allUnsavedPanelsMap); })(); if (unsavedDashboardState) { 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 index e9ae3d6a1505..0fc8ce7173e6 100644 --- a/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts +++ b/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts @@ -22,7 +22,7 @@ import type { ViewMode } from '@kbn/embeddable-plugin/common'; import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; import { SEARCH_SESSION_ID } from '../../dashboard_constants'; import { DashboardLocatorParams } from '../../dashboard_container'; -import { convertPanelMapToSavedPanels } from '../../../common'; +import { convertPanelMapToPanelsArray } from '../../../common'; import { dataService } from '../../services/kibana_services'; import { DashboardApi } from '../../dashboard_api/types'; @@ -93,7 +93,7 @@ function getLocatorParams({ : undefined, panels: savedObjectId ? undefined - : (convertPanelMapToSavedPanels( + : (convertPanelMapToPanelsArray( dashboardApi.panels$.value ) as DashboardLocatorParams['panels']), }; diff --git a/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts b/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts index b748909eac9a..87faf87b026f 100644 --- a/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts +++ b/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts @@ -19,24 +19,29 @@ import { DashboardContainerInput, DashboardPanelMap, SharedDashboardState, - convertSavedPanelsToPanelMap, + convertPanelsArrayToPanelMap, } from '../../../common'; -import { SavedDashboardPanel } from '../../../common/content_management'; +import type { DashboardPanel } from '../../../server/content_management'; +import type { SavedDashboardPanel } from '../../../server/dashboard_saved_object'; import { DashboardApi } from '../../dashboard_api/types'; import { DASHBOARD_STATE_STORAGE_KEY, createDashboardEditUrl } from '../../dashboard_constants'; import { migrateLegacyQuery } from '../../services/dashboard_content_management_service/lib/load_dashboard_state'; import { coreServices } from '../../services/kibana_services'; import { getPanelTooOldErrorString } from '../_dashboard_app_strings'; +const panelIsLegacy = (panel: unknown): panel is SavedDashboardPanel => { + return (panel as SavedDashboardPanel).embeddableConfig !== undefined; +}; + /** * 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[]) => { +export const isPanelVersionTooOld = (panels: DashboardPanel[] | SavedDashboardPanel[]) => { for (const panel of panels) { if ( !panel.gridData || - !panel.embeddableConfig || + !((panel as DashboardPanel).panelConfig || (panel as SavedDashboardPanel).embeddableConfig) || (panel.version && semverSatisfies(panel.version, '<7.3')) ) return true; @@ -58,7 +63,19 @@ function getPanelsMap(appStateInUrl: SharedDashboardState): DashboardPanelMap | return undefined; } - return convertSavedPanelsToPanelMap(appStateInUrl.panels); + // convert legacy embeddableConfig keys to panelConfig + const panels = appStateInUrl.panels.map((panel) => { + if (panelIsLegacy(panel)) { + const { embeddableConfig, ...rest } = panel; + return { + ...rest, + panelConfig: embeddableConfig, + }; + } + return panel; + }); + + return convertPanelsArrayToPanelMap(panels); } /** 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 index 444bac28c9e6..e5355bdb2988 100644 --- 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 @@ -27,6 +27,7 @@ import { DashboardPanelMap, prefixReferencesFromPanel, } from '../../../../common'; +import type { DashboardAttributes } from '../../../../server/content_management'; import { DASHBOARD_CONTENT_ID, SAVED_OBJECT_POST_TIME } from '../../../dashboard_constants'; import { SaveDashboardReturn, @@ -95,7 +96,11 @@ export async function runQuickSave(this: DashboardContainer) { const { rawState: controlGroupSerializedState, references: extractedReferences } = await controlGroupApi.serializeState(); controlGroupReferences = extractedReferences; - stateToSave = { ...stateToSave, controlGroupInput: controlGroupSerializedState }; + stateToSave = { + ...stateToSave, + controlGroupInput: + controlGroupSerializedState as unknown as DashboardAttributes['controlGroupInput'], + }; } const saveResult = await getDashboardContentManagementService().saveDashboardState({ @@ -186,7 +191,8 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo controlGroupReferences = references; dashboardStateToSave = { ...dashboardStateToSave, - controlGroupInput: controlGroupSerializedState, + controlGroupInput: + controlGroupSerializedState as unknown as DashboardAttributes['controlGroupInput'], }; } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index a6765732c064..35137075befe 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -25,7 +25,7 @@ import { v4 } from 'uuid'; import { METRIC_TYPE } from '@kbn/analytics'; import type { Reference } from '@kbn/content-management-utils'; -import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public'; +import { ControlGroupApi } from '@kbn/controls-plugin/public'; import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public'; import { RefreshInterval } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -69,12 +69,8 @@ import { LocatorPublic } from '@kbn/share-plugin/common'; import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen'; import { DASHBOARD_CONTAINER_TYPE, DashboardApi, DashboardLocatorParams } from '../..'; -import { - DashboardAttributes, - DashboardContainerInput, - DashboardPanelMap, - DashboardPanelState, -} from '../../../common'; +import type { DashboardAttributes } from '../../../server/content_management'; +import { DashboardContainerInput, DashboardPanelMap, DashboardPanelState } from '../../../common'; import { getReferencesForControls, getReferencesForPanelId, @@ -887,15 +883,19 @@ export class DashboardContainer public getSerializedStateForControlGroup = () => { return { rawState: this.controlGroupInput - ? (this.controlGroupInput as ControlGroupSerializedState) - : ({ - controlStyle: 'oneLine', + ? this.controlGroupInput + : { + labelPosition: 'oneLine', chainingSystem: 'HIERARCHICAL', - showApplySelections: false, - panelsJSON: '{}', - ignoreParentSettingsJSON: - '{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}', - } as ControlGroupSerializedState), + autoApplySelections: true, + controls: [], + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, + }, references: getReferencesForControls(this.savedObjectReferences), }; }; diff --git a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_clone_panel_strategy.ts b/src/plugins/dashboard/public/dashboard_container/panel_placement/place_clone_panel_strategy.ts index 8386df50717f..bdf5a39df34b 100644 --- a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_clone_panel_strategy.ts +++ b/src/plugins/dashboard/public/dashboard_container/panel_placement/place_clone_panel_strategy.ts @@ -11,7 +11,7 @@ import { cloneDeep, forOwn } from 'lodash'; import { PanelNotFoundError } from '@kbn/embeddable-plugin/public'; import { DashboardPanelState } from '../../../common'; -import { GridData } from '../../../common/content_management'; +import type { GridData } from '../../../server/content_management'; import { PanelPlacementProps, PanelPlacementReturn } from './types'; import { DASHBOARD_GRID_COLUMN_COUNT } from '../../dashboard_constants'; @@ -109,9 +109,9 @@ export function placeClonePanel({ for (let j = position + 1; j < grid.length; j++) { originalPositionInTheGrid = grid[j].i; - const movedPanel = cloneDeep(otherPanels[originalPositionInTheGrid]); - movedPanel.gridData.y = movedPanel.gridData.y + diff; - otherPanels[originalPositionInTheGrid] = movedPanel; + const { gridData, ...movedPanel } = cloneDeep(otherPanels[originalPositionInTheGrid]); + const newGridData = { ...gridData, y: gridData.y + diff }; + otherPanels[originalPositionInTheGrid] = { ...movedPanel, gridData: newGridData }; } return { newPanelPlacement: bottomPlacement.grid, otherPanels }; } diff --git a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_new_panel_strategies.ts b/src/plugins/dashboard/public/dashboard_container/panel_placement/place_new_panel_strategies.ts index 821a5e6eed1c..a6c0aaba4346 100644 --- a/src/plugins/dashboard/public/dashboard_container/panel_placement/place_new_panel_strategies.ts +++ b/src/plugins/dashboard/public/dashboard_container/panel_placement/place_new_panel_strategies.ts @@ -20,9 +20,9 @@ export const runPanelPlacementStrategy = ( case PanelPlacementStrategy.placeAtTop: const otherPanels = { ...currentPanels }; for (const [id, panel] of Object.entries(currentPanels)) { - const currentPanel = cloneDeep(panel); - currentPanel.gridData.y = currentPanel.gridData.y + height; - otherPanels[id] = currentPanel; + const { gridData, ...currentPanel } = cloneDeep(panel); + const newGridData = { ...gridData, y: gridData.y + height }; + otherPanels[id] = { ...currentPanel, gridData: newGridData }; } return { newPanelPlacement: { x: 0, y: 0, w: width, h: height }, diff --git a/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts b/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts index df34a9158c11..2dd826f9a582 100644 --- a/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts +++ b/src/plugins/dashboard/public/dashboard_container/panel_placement/types.ts @@ -10,7 +10,7 @@ import { EmbeddableInput } from '@kbn/embeddable-plugin/public'; import { MaybePromise } from '@kbn/utility-types'; import { DashboardPanelState } from '../../../common'; -import { GridData } from '../../../common/content_management'; +import type { GridData } from '../../../server/content_management'; import { PanelPlacementStrategy } from '../../dashboard_constants'; export interface PanelPlacementSettings { diff --git a/src/plugins/dashboard/public/dashboard_container/types.ts b/src/plugins/dashboard/public/dashboard_container/types.ts index f0b6ea2621ab..cf307924e00f 100644 --- a/src/plugins/dashboard/public/dashboard_container/types.ts +++ b/src/plugins/dashboard/public/dashboard_container/types.ts @@ -12,8 +12,8 @@ import type { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public' import { SerializableRecord } from '@kbn/utility-types'; import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public'; -import type { DashboardContainerInput, DashboardOptions } from '../../common'; -import { SavedDashboardPanel } from '../../common/content_management'; +import type { DashboardContainerInput } from '../../common'; +import type { DashboardOptions, DashboardPanel } from '../../server/content_management'; export interface UnsavedPanelState { [key: string]: object | undefined; @@ -101,7 +101,7 @@ export type DashboardLocatorParams = Partial< /** * List of dashboard panels */ - panels?: Array; // used SerializableRecord here to force the GridData type to be read as serializable + panels?: Array; // used SerializableRecord here to force the GridData type to be read as serializable /** * Control group changes diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx index 0e2358380130..04f40a199e83 100644 --- a/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx @@ -20,7 +20,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { DashboardAttributes } from '../../common/content_management'; +import type { DashboardAttributes } from '../../server/content_management'; import { DASHBOARD_PANELS_UNSAVED_ID, getDashboardBackupService, diff --git a/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx index 31bfa88120a5..6c8c8f11d6a1 100644 --- a/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx @@ -17,7 +17,7 @@ import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import { DashboardContainerInput } from '../../../common'; -import { DashboardItem } from '../../../common/content_management'; +import type { DashboardSearchOut } from '../../../server/content_management'; import { DASHBOARD_CONTENT_ID, SAVED_OBJECT_DELETE_TIME, @@ -42,7 +42,9 @@ type GetDetailViewLink = const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit'; const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage'; -const toTableListViewSavedObject = (hit: DashboardItem): DashboardSavedObjectUserContent => { +const toTableListViewSavedObject = ( + hit: DashboardSearchOut['hits'][number] +): DashboardSavedObjectUserContent => { const { title, description, timeRestore } = hit.attributes; return { type: 'dashboard', @@ -51,7 +53,7 @@ const toTableListViewSavedObject = (hit: DashboardItem): DashboardSavedObjectUse createdAt: hit.createdAt, createdBy: hit.createdBy, updatedBy: hit.updatedBy, - references: hit.references, + references: hit.references ?? [], managed: hit.managed, attributes: { title, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/dashboard_content_management_cache.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/dashboard_content_management_cache.ts index 1b54f9dda9eb..e72e3f23fdab 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/dashboard_content_management_cache.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/dashboard_content_management_cache.ts @@ -8,14 +8,14 @@ */ import LRUCache from 'lru-cache'; -import { DashboardCrudTypes } from '../../../common/content_management'; +import type { DashboardGetOut } from '../../../server/content_management'; import { DASHBOARD_CACHE_SIZE, DASHBOARD_CACHE_TTL } from '../../dashboard_constants'; export class DashboardContentManagementCache { - private cache: LRUCache; + private cache: LRUCache; constructor() { - this.cache = new LRUCache({ + this.cache = new LRUCache({ max: DASHBOARD_CACHE_SIZE, maxAge: DASHBOARD_CACHE_TTL, }); @@ -27,7 +27,7 @@ export class DashboardContentManagementCache { } /** Add the fetched dashboard to the cache */ - public addDashboard({ item: dashboard, meta }: DashboardCrudTypes['GetOut']) { + public addDashboard({ item: dashboard, meta }: DashboardGetOut) { this.cache.set(dashboard.id, { meta, item: dashboard, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/check_for_duplicate_dashboard_title.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/check_for_duplicate_dashboard_title.ts index 0d12cb446129..2865663dec3c 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/check_for_duplicate_dashboard_title.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/check_for_duplicate_dashboard_title.ts @@ -7,8 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { DashboardSearchIn, DashboardSearchOut } from '../../../../server/content_management'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; -import { DashboardCrudTypes } from '../../../../common/content_management'; import { extractTitleAndCount } from '../../../dashboard_container/embeddable/api/lib/extract_title_and_count'; import { contentManagementService } from '../../kibana_services'; @@ -54,8 +54,8 @@ export async function checkForDuplicateDashboardTitle({ const [baseDashboardName] = extractTitleAndCount(title); const { hits } = await contentManagementService.client.search< - DashboardCrudTypes['SearchIn'], - DashboardCrudTypes['SearchOut'] + DashboardSearchIn, + DashboardSearchOut >({ contentTypeId: DASHBOARD_CONTENT_ID, query: { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/delete_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/delete_dashboards.ts index 0be9355ddb60..976a5579b198 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/delete_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/delete_dashboards.ts @@ -7,18 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { getDashboardContentManagementCache } from '..'; -import { DashboardCrudTypes } from '../../../../common/content_management'; +import type { DeleteIn, DeleteResult } from '@kbn/content-management-plugin/common'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; +import { getDashboardContentManagementCache } from '..'; import { contentManagementService } from '../../kibana_services'; export const deleteDashboards = async (ids: string[]) => { const deletePromises = ids.map((id) => { getDashboardContentManagementCache().deleteDashboard(id); - return contentManagementService.client.delete< - DashboardCrudTypes['DeleteIn'], - DashboardCrudTypes['DeleteOut'] - >({ + return contentManagementService.client.delete({ contentTypeId: DASHBOARD_CONTENT_ID, id, }); diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/find_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/find_dashboards.ts index 2f9a2c2e9a03..4afdefb8d13e 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/find_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/find_dashboards.ts @@ -10,17 +10,20 @@ import type { Reference } from '@kbn/content-management-utils'; import { SavedObjectError, SavedObjectsFindOptionsReference } from '@kbn/core/public'; -import { getDashboardContentManagementCache } from '..'; -import { +import type { DashboardAttributes, - DashboardCrudTypes, - DashboardItem, -} from '../../../../common/content_management'; + DashboardGetIn, + DashboardGetOut, + DashboardSearchIn, + DashboardSearchOut, + DashboardSearchOptions, +} from '../../../../server/content_management'; +import { getDashboardContentManagementCache } from '..'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; import { contentManagementService } from '../../kibana_services'; export interface SearchDashboardsArgs { - options?: DashboardCrudTypes['SearchIn']['options']; + options?: DashboardSearchOptions; hasNoReference?: SavedObjectsFindOptionsReference[]; hasReference?: SavedObjectsFindOptionsReference[]; search: string; @@ -29,7 +32,7 @@ export interface SearchDashboardsArgs { export interface SearchDashboardsResponse { total: number; - hits: DashboardItem[]; + hits: DashboardSearchOut['hits']; } export async function searchDashboards({ @@ -42,10 +45,7 @@ export async function searchDashboards({ const { hits, pagination: { total }, - } = await contentManagementService.client.search< - DashboardCrudTypes['SearchIn'], - DashboardCrudTypes['SearchOut'] - >({ + } = await contentManagementService.client.search({ contentTypeId: DASHBOARD_CONTENT_ID, query: { text: search ? `${search}*` : undefined, @@ -84,10 +84,7 @@ export async function findDashboardById(id: string): Promise({ + const response = await contentManagementService.client.get({ contentTypeId: DASHBOARD_CONTENT_ID, id, }); @@ -119,8 +116,8 @@ export async function findDashboardsByIds(ids: string[]): Promise { const { hits } = await contentManagementService.client.search< - DashboardCrudTypes['SearchIn'], - DashboardCrudTypes['SearchOut'] + DashboardSearchIn, + DashboardSearchOut >({ contentTypeId: DASHBOARD_CONTENT_ID, query: { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts index 17102e2fe7d0..2694411ed001 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts @@ -10,19 +10,15 @@ import { has } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; -import { injectSearchSourceReferences, parseSearchSourceJSON } from '@kbn/data-plugin/public'; +import { injectSearchSourceReferences } from '@kbn/data-plugin/public'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import { Filter, Query } from '@kbn/es-query'; import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; import { cleanFiltersForSerialize } from '@kbn/presentation-util-plugin/public'; import { getDashboardContentManagementCache } from '..'; -import { - convertSavedPanelsToPanelMap, - injectReferences, - type DashboardOptions, -} from '../../../../common'; -import { DashboardCrudTypes } from '../../../../common/content_management'; +import { convertPanelsArrayToPanelMap, injectReferences } from '../../../../common'; +import type { DashboardGetIn, DashboardGetOut } from '../../../../server/content_management'; import { DASHBOARD_CONTENT_ID, DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants'; import { contentManagementService, @@ -30,7 +26,11 @@ import { embeddableService, savedObjectsTaggingService, } from '../../kibana_services'; -import type { LoadDashboardFromSavedObjectProps, LoadDashboardReturn } from '../types'; +import type { + DashboardSearchSource, + LoadDashboardFromSavedObjectProps, + LoadDashboardReturn, +} from '../types'; import { convertNumberToDashboardVersion } from './dashboard_versioning'; import { migrateDashboardInput } from './migrate_dashboard_input'; @@ -72,8 +72,8 @@ export const loadDashboardState = async ({ /** * Load the saved object from Content Management */ - let rawDashboardContent: DashboardCrudTypes['GetOut']['item']; - let resolveMeta: DashboardCrudTypes['GetOut']['meta']; + let rawDashboardContent: DashboardGetOut['item']; + let resolveMeta: DashboardGetOut['meta']; const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id); if (cachedDashboard) { @@ -82,7 +82,7 @@ export const loadDashboardState = async ({ } else { /** Otherwise, fetch and load the dashboard from the content management client, and add it to the cache */ const result = await contentManagementService.client - .get({ + .get({ contentTypeId: DASHBOARD_CONTENT_ID, id, }) @@ -127,14 +127,16 @@ export const loadDashboardState = async ({ /** * Create search source and pull filters and query from it. */ - const searchSourceJSON = attributes.kibanaSavedObjectMeta.searchSourceJSON; + let searchSourceValues = attributes.kibanaSavedObjectMeta.searchSource; const searchSource = await (async () => { - if (!searchSourceJSON) { + if (!searchSourceValues) { return await dataSearchService.searchSource.create(); } try { - let searchSourceValues = parseSearchSourceJSON(searchSourceJSON); - searchSourceValues = injectSearchSourceReferences(searchSourceValues as any, references); + searchSourceValues = injectSearchSourceReferences( + searchSourceValues as any, + references + ) as DashboardSearchSource; return await dataSearchService.searchSource.create(searchSourceValues); } catch (error: any) { return await dataSearchService.searchSource.create(); @@ -151,8 +153,8 @@ export const loadDashboardState = async ({ refreshInterval, description, timeRestore, - optionsJSON, - panelsJSON, + options, + panels, timeFrom, version, timeTo, @@ -167,11 +169,7 @@ export const loadDashboardState = async ({ } : undefined; - /** - * Parse panels and options from JSON - */ - const options: DashboardOptions = optionsJSON ? JSON.parse(optionsJSON) : undefined; - const panels = convertSavedPanelsToPanelMap(panelsJSON ? JSON.parse(panelsJSON) : []); + const panelMap = convertPanelsArrayToPanelMap(panels ?? []); const { dashboardInput, anyMigrationRun } = migrateDashboardInput({ ...DEFAULT_DASHBOARD_INPUT, @@ -183,7 +181,7 @@ export const loadDashboardState = async ({ description, timeRange, filters, - panels, + panels: panelMap, query, title, @@ -192,7 +190,7 @@ export const loadDashboardState = async ({ controlGroupInput: attributes.controlGroupInput, - version: convertNumberToDashboardVersion(version), + ...(version && { version: convertNumberToDashboardVersion(version) }), }); return { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts index 8327397a6606..7e35b0ec1c16 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.test.ts @@ -95,7 +95,7 @@ describe('Save dashboard state', () => { currentState: { ...getSampleDashboardInput(), title: 'BooThree', - panels: { idOne: { type: 'boop' } }, + panels: { aVerySpecialVeryUniqueId: { type: 'boop' } }, } as unknown as DashboardContainerInput, lastSavedId: 'Boogatoonie', saveOptions: { saveAsCopy: true }, @@ -106,7 +106,11 @@ describe('Save dashboard state', () => { expect(contentManagementService.client.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ - panelsJSON: expect.not.stringContaining('neverGonnaGetThisId'), + panels: expect.arrayContaining([ + expect.objectContaining({ + panelIndex: expect.not.stringContaining('aVerySpecialVeryUniqueId'), + }), + ]), }), }) ); diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts index 283ed5eed7f5..27e6a53da1f9 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/save_dashboard_state.ts @@ -13,9 +13,16 @@ import moment, { Moment } from 'moment'; import { extractSearchSourceReferences, RefreshInterval } from '@kbn/data-plugin/public'; import { isFilterPinned } from '@kbn/es-query'; +import type { SavedObjectReference } from '@kbn/core/server'; import { getDashboardContentManagementCache } from '..'; -import { convertPanelMapToSavedPanels, extractReferences } from '../../../../common'; -import { DashboardAttributes, DashboardCrudTypes } from '../../../../common/content_management'; +import { convertPanelMapToPanelsArray, extractReferences } from '../../../../common'; +import type { + DashboardAttributes, + DashboardCreateIn, + DashboardCreateOut, + DashboardUpdateIn, + DashboardUpdateOut, +} from '../../../../server/content_management'; import { generateNewPanelIds } from '../../../../common/lib/dashboard_panel_converters'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; import { LATEST_DASHBOARD_CONTAINER_VERSION } from '../../../dashboard_container'; @@ -28,7 +35,7 @@ import { embeddableService, savedObjectsTaggingService, } from '../../kibana_services'; -import { SaveDashboardProps, SaveDashboardReturn } from '../types'; +import { DashboardSearchSource, SaveDashboardProps, SaveDashboardReturn } from '../types'; import { convertDashboardVersionToNumber } from './dashboard_versioning'; export const convertTimeToUTCString = (time?: string | Moment): undefined | string => { @@ -88,33 +95,30 @@ export const saveDashboardState = async ({ // } - /** - * Stringify filters and query into search source JSON - */ - const { searchSourceJSON, searchSourceReferences } = await (async () => { - const searchSource = await dataSearchService.searchSource.create(); - searchSource.setField( + const { searchSource, searchSourceReferences } = await (async () => { + const searchSourceFields = await dataSearchService.searchSource.create(); + searchSourceFields.setField( 'filter', // save only unpinned filters filters.filter((filter) => !isFilterPinned(filter)) ); - searchSource.setField('query', query); - - const rawSearchSourceFields = searchSource.getSerializedFields(); - const [fields, references] = extractSearchSourceReferences(rawSearchSourceFields); - return { searchSourceReferences: references, searchSourceJSON: JSON.stringify(fields) }; + searchSourceFields.setField('query', query); + + const rawSearchSourceFields = searchSourceFields.getSerializedFields(); + const [fields, references] = extractSearchSourceReferences(rawSearchSourceFields) as [ + DashboardSearchSource, + SavedObjectReference[] + ]; + return { searchSourceReferences: references, searchSource: fields }; })(); - /** - * Stringify options and panels - */ - const optionsJSON = JSON.stringify({ + const options = { useMargins, syncColors, syncCursor, syncTooltips, hidePanelTitles, - }); - const panelsJSON = JSON.stringify(convertPanelMapToSavedPanels(panels, true)); + }; + const savedPanels = convertPanelMapToPanelsArray(panels, true); /** * Parse global time filter settings @@ -134,12 +138,12 @@ export const saveDashboardState = async ({ const rawDashboardAttributes: DashboardAttributes = { version: convertDashboardVersionToNumber(LATEST_DASHBOARD_CONTAINER_VERSION), controlGroupInput, - kibanaSavedObjectMeta: { searchSourceJSON }, + kibanaSavedObjectMeta: { searchSource }, description: description ?? '', refreshInterval, timeRestore, - optionsJSON, - panelsJSON, + options, + panels: savedPanels, timeFrom, title, timeTo, @@ -174,10 +178,7 @@ export const saveDashboardState = async ({ try { const result = idToSaveTo - ? await contentManagementService.client.update< - DashboardCrudTypes['UpdateIn'], - DashboardCrudTypes['UpdateOut'] - >({ + ? await contentManagementService.client.update({ id: idToSaveTo, contentTypeId: DASHBOARD_CONTENT_ID, data: attributes, @@ -187,10 +188,7 @@ export const saveDashboardState = async ({ mergeAttributes: false, }, }) - : await contentManagementService.client.create< - DashboardCrudTypes['CreateIn'], - DashboardCrudTypes['CreateOut'] - >({ + : await contentManagementService.client.create({ contentTypeId: DASHBOARD_CONTENT_ID, data: attributes, options: { diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/update_dashboard_meta.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/update_dashboard_meta.ts index 2fd57738f17a..90f31cfdc05c 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/update_dashboard_meta.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/lib/update_dashboard_meta.ts @@ -9,7 +9,7 @@ import { DashboardContainerInput } from '../../../../common'; import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; -import { DashboardCrudTypes } from '../../../../common/content_management'; +import type { DashboardUpdateIn, DashboardUpdateOut } from '../../../../server/content_management'; import { findDashboardsByIds } from './find_dashboards'; import { contentManagementService, savedObjectsTaggingService } from '../../kibana_services'; @@ -35,10 +35,7 @@ export const updateDashboardMeta = async ({ ? savedObjectsTaggingApi.ui.updateTagsReferences(dashboard.references, tags) : dashboard.references; - await contentManagementService.client.update< - DashboardCrudTypes['UpdateIn'], - DashboardCrudTypes['UpdateOut'] - >({ + await contentManagementService.client.update({ contentTypeId: DASHBOARD_CONTENT_ID, id, data: { title, description }, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts b/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts index 3294bb06c0d4..0f4fe1c86a56 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management_service/types.ts @@ -8,11 +8,12 @@ */ import type { Reference } from '@kbn/content-management-utils'; +import type { Query, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public'; import { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public'; import { DashboardContainerInput } from '../../../common'; -import { DashboardAttributes, DashboardCrudTypes } from '../../../common/content_management'; +import type { DashboardAttributes, DashboardGetOut } from '../../../server/content_management'; import { DashboardDuplicateTitleCheckProps } from './lib/check_for_duplicate_dashboard_title'; import { FindDashboardsByIdResponse, @@ -38,7 +39,7 @@ export interface LoadDashboardFromSavedObjectProps { id?: string; } -type DashboardResolveMeta = DashboardCrudTypes['GetOut']['meta']; +type DashboardResolveMeta = DashboardGetOut['meta']; export type SavedDashboardInput = DashboardContainerInput & { /** @@ -54,6 +55,10 @@ export type SavedDashboardInput = DashboardContainerInput & { controlGroupState?: Partial; }; +export type DashboardSearchSource = Omit & { + query?: Query; +}; + export interface LoadDashboardReturn { dashboardFound: boolean; newDashboardCreated?: boolean; diff --git a/src/plugins/dashboard/public/services/mocks.ts b/src/plugins/dashboard/public/services/mocks.ts index 255098ecd819..c39c665ed55d 100644 --- a/src/plugins/dashboard/public/services/mocks.ts +++ b/src/plugins/dashboard/public/services/mocks.ts @@ -32,7 +32,8 @@ import { urlForwardingPluginMock } from '@kbn/url-forwarding-plugin/public/mocks import { visualizationsPluginMock } from '@kbn/visualizations-plugin/public/mocks'; import { setKibanaServices } from './kibana_services'; -import { DashboardAttributes, DashboardCapabilities } from '../../common'; +import { DashboardAttributes } from '../../server/content_management'; +import { DashboardCapabilities } from '../../common'; import { LoadDashboardReturn } from './dashboard_content_management_service/types'; import { SearchDashboardsResponse } from './dashboard_content_management_service/lib/find_dashboards'; diff --git a/src/plugins/dashboard/server/api/constants.ts b/src/plugins/dashboard/server/api/constants.ts new file mode 100644 index 000000000000..458165d79786 --- /dev/null +++ b/src/plugins/dashboard/server/api/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export const PUBLIC_API_VERSION = '2023-10-31'; +export const PUBLIC_API_CONTENT_MANAGEMENT_VERSION = 3; +export const PUBLIC_API_PATH = '/api/dashboards/dashboard'; diff --git a/src/plugins/dashboard/server/content_management/schema/v2/index.ts b/src/plugins/dashboard/server/api/index.ts similarity index 80% rename from src/plugins/dashboard/server/content_management/schema/v2/index.ts rename to src/plugins/dashboard/server/api/index.ts index 66beda1385d0..ccf84609b2b1 100644 --- a/src/plugins/dashboard/server/content_management/schema/v2/index.ts +++ b/src/plugins/dashboard/server/api/index.ts @@ -7,8 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { - serviceDefinition, - dashboardSavedObjectSchema, - dashboardAttributesSchema, -} from './cm_services'; +export { registerAPIRoutes } from './register_routes'; diff --git a/src/plugins/dashboard/server/api/register_routes.ts b/src/plugins/dashboard/server/api/register_routes.ts new file mode 100644 index 000000000000..692942e1bd1b --- /dev/null +++ b/src/plugins/dashboard/server/api/register_routes.ts @@ -0,0 +1,327 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { schema } from '@kbn/config-schema'; +import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; +import type { HttpServiceSetup } from '@kbn/core/server'; +import type { UsageCounter } from '@kbn/usage-collection-plugin/server'; +import type { Logger } from '@kbn/logging'; + +import { CONTENT_ID } from '../../common/content_management'; +import { + PUBLIC_API_PATH, + PUBLIC_API_VERSION, + PUBLIC_API_CONTENT_MANAGEMENT_VERSION, +} from './constants'; +import { + dashboardAttributesSchema, + dashboardGetResultSchema, + dashboardCreateResultSchema, + dashboardSearchResultsSchema, + referenceSchema, +} from '../content_management/v3'; + +interface RegisterAPIRoutesArgs { + http: HttpServiceSetup; + contentManagement: ContentManagementServerSetup; + restCounter?: UsageCounter; + logger: Logger; +} + +const TECHNICAL_PREVIEW_WARNING = + 'This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.'; + +export function registerAPIRoutes({ + http, + contentManagement, + restCounter, + logger, +}: RegisterAPIRoutesArgs) { + const { versioned: versionedRouter } = http.createRouter(); + + // Create API route + const createRoute = versionedRouter.post({ + path: `${PUBLIC_API_PATH}/{id?}`, + access: 'public', + summary: 'Create a dashboard', + description: TECHNICAL_PREVIEW_WARNING, + options: { + tags: ['oas-tag:Dashboards'], + }, + }); + + createRoute.addVersion( + { + version: PUBLIC_API_VERSION, + validate: { + request: { + params: schema.object({ + id: schema.maybe(schema.string()), + }), + body: schema.object({ + attributes: dashboardAttributesSchema, + references: schema.maybe(schema.arrayOf(referenceSchema)), + spaces: schema.maybe(schema.arrayOf(schema.string())), + }), + }, + response: { + 200: { + body: () => dashboardCreateResultSchema, + }, + }, + }, + }, + async (ctx, req, res) => { + const { id } = req.params; + const { attributes, references, spaces: initialNamespaces } = req.body; + const client = contentManagement.contentClient + .getForRequest({ request: req, requestHandlerContext: ctx }) + .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + let result; + try { + ({ result } = await client.create(attributes, { + id, + references, + initialNamespaces, + })); + } catch (e) { + if (e.isBoom && e.output.statusCode === 409) { + return res.conflict({ + body: { + message: `A dashboard with saved object ID ${id} already exists.`, + }, + }); + } + + if (e.isBoom && e.output.statusCode === 403) { + return res.forbidden(); + } + + return res.badRequest(); + } + + return res.ok({ body: result }); + } + ); + + // Update API route + + const updateRoute = versionedRouter.put({ + path: `${PUBLIC_API_PATH}/{id}`, + access: 'public', + summary: `Update an existing dashboard.`, + description: TECHNICAL_PREVIEW_WARNING, + options: { + tags: ['oas-tag:Dashboards'], + }, + }); + + updateRoute.addVersion( + { + version: PUBLIC_API_VERSION, + validate: { + request: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + attributes: dashboardAttributesSchema, + references: schema.maybe(schema.arrayOf(referenceSchema)), + }), + }, + response: { + 200: { + body: () => dashboardCreateResultSchema, + }, + }, + }, + }, + async (ctx, req, res) => { + const { attributes, references } = req.body; + const client = contentManagement.contentClient + .getForRequest({ request: req, requestHandlerContext: ctx }) + .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + let result; + try { + ({ result } = await client.update(req.params.id, attributes, { references })); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return res.notFound({ + body: { + message: `A dashboard with saved object ID ${req.params.id} was not found.`, + }, + }); + } + if (e.isBoom && e.output.statusCode === 403) { + return res.forbidden(); + } + return res.badRequest(e.message); + } + + return res.created({ body: result }); + } + ); + + // List API route + const listRoute = versionedRouter.get({ + path: `${PUBLIC_API_PATH}`, + access: 'public', + summary: `Get a list of dashboards.`, + description: TECHNICAL_PREVIEW_WARNING, + options: { + tags: ['oas-tag:Dashboards'], + }, + }); + + listRoute.addVersion( + { + version: PUBLIC_API_VERSION, + validate: { + request: { + query: schema.object({ + page: schema.number({ defaultValue: 1 }), + perPage: schema.maybe(schema.number()), + }), + }, + response: { + 200: { + body: () => + schema.object({ + items: schema.arrayOf(dashboardSearchResultsSchema), + total: schema.number(), + }), + }, + }, + }, + }, + async (ctx, req, res) => { + const { page, perPage: limit } = req.query; + const client = contentManagement.contentClient + .getForRequest({ request: req, requestHandlerContext: ctx }) + .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + let result; + try { + // TODO add filtering + ({ result } = await client.search({ cursor: page.toString(), limit })); + } catch (e) { + if (e.isBoom && e.output.statusCode === 403) { + return res.forbidden(); + } + + return res.badRequest(); + } + + const body = { + items: result.hits, + total: result.pagination.total, + }; + return res.ok({ body }); + } + ); + + // Get API route + const getRoute = versionedRouter.get({ + path: `${PUBLIC_API_PATH}/{id}`, + access: 'public', + summary: `Get a dashboard.`, + description: TECHNICAL_PREVIEW_WARNING, + options: { + tags: ['oas-tag:Dashboards'], + }, + }); + + getRoute.addVersion( + { + version: PUBLIC_API_VERSION, + validate: { + request: { + params: schema.object({ + id: schema.string(), + }), + }, + response: { + 200: { + body: () => dashboardGetResultSchema, + }, + }, + }, + }, + async (ctx, req, res) => { + const client = contentManagement.contentClient + .getForRequest({ request: req, requestHandlerContext: ctx }) + .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + let result; + try { + ({ result } = await client.get(req.params.id)); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return res.notFound({ + body: { + message: `A dashboard with saved object ID ${req.params.id}] was not found.`, + }, + }); + } + + if (e.isBoom && e.output.statusCode === 403) { + return res.forbidden(); + } + + return res.badRequest(e.message); + } + + return res.ok({ body: result }); + } + ); + + // Delete API route + const deleteRoute = versionedRouter.delete({ + path: `${PUBLIC_API_PATH}/{id}`, + access: 'public', + summary: `Delete a dashboard.`, + description: TECHNICAL_PREVIEW_WARNING, + options: { + tags: ['oas-tag:Dashboards'], + }, + }); + + deleteRoute.addVersion( + { + version: PUBLIC_API_VERSION, + validate: { + request: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + }, + async (ctx, req, res) => { + const client = contentManagement.contentClient + .getForRequest({ request: req, requestHandlerContext: ctx }) + .for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION); + try { + await client.delete(req.params.id); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return res.notFound({ + body: { + message: `A dashboard with saved object ID ${req.params.id} was not found.`, + }, + }); + } + if (e.isBoom && e.output.statusCode === 403) { + return res.forbidden(); + } + return res.badRequest(); + } + + return res.ok(); + } + ); +} diff --git a/src/plugins/dashboard/server/content_management/schema/cm_services.ts b/src/plugins/dashboard/server/content_management/cm_services.ts similarity index 94% rename from src/plugins/dashboard/server/content_management/schema/cm_services.ts rename to src/plugins/dashboard/server/content_management/cm_services.ts index 10fbbd7f44ba..081d7ad8a39d 100644 --- a/src/plugins/dashboard/server/content_management/schema/cm_services.ts +++ b/src/plugins/dashboard/server/content_management/cm_services.ts @@ -17,8 +17,10 @@ import type { import { serviceDefinition as v1 } from './v1'; import { serviceDefinition as v2 } from './v2'; +import { serviceDefinition as v3 } from './v3'; export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = { 1: v1, 2: v2, + 3: v3, }; diff --git a/src/plugins/dashboard/server/content_management/dashboard_storage.ts b/src/plugins/dashboard/server/content_management/dashboard_storage.ts index 248979032132..e65002802989 100644 --- a/src/plugins/dashboard/server/content_management/dashboard_storage.ts +++ b/src/plugins/dashboard/server/content_management/dashboard_storage.ts @@ -7,23 +7,40 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SOContentStorage, tagsToFindOptions } from '@kbn/content-management-utils'; -import { SavedObjectsFindOptions } from '@kbn/core-saved-objects-api-server'; +import Boom from '@hapi/boom'; +import { tagsToFindOptions } from '@kbn/content-management-utils'; +import { + SavedObjectsFindOptions, + SavedObjectsFindResult, +} from '@kbn/core-saved-objects-api-server'; import type { Logger } from '@kbn/logging'; -import { CONTENT_ID } from '../../common/content_management'; -import { cmServicesDefinition } from './schema/cm_services'; -import type { DashboardCrudTypes } from '../../common/content_management'; +import { CreateResult, DeleteResult, SearchQuery } from '@kbn/content-management-plugin/common'; +import { StorageContext } from '@kbn/content-management-plugin/server'; +import { DASHBOARD_SAVED_OBJECT_TYPE } from '../dashboard_saved_object'; +import { cmServicesDefinition } from './cm_services'; +import { DashboardSavedObjectAttributes } from '../dashboard_saved_object'; +import { itemAttrsToSavedObjectAttrs, savedObjectToItem } from './latest'; +import type { + DashboardAttributes, + DashboardItem, + DashboardCreateOut, + DashboardCreateOptions, + DashboardGetOut, + DashboardSearchOut, + DashboardUpdateOptions, + DashboardUpdateOut, + DashboardSearchOptions, +} from './latest'; const searchArgsToSOFindOptions = ( - args: DashboardCrudTypes['SearchIn'] + query: SearchQuery, + options: DashboardSearchOptions ): SavedObjectsFindOptions => { - const { query, contentTypeId, options } = args; - return { - type: contentTypeId, + type: DASHBOARD_SAVED_OBJECT_TYPE, searchFields: options?.onlyTitle ? ['title'] : ['title^3', 'description'], - fields: ['description', 'title', 'timeRestore'], + fields: options?.fields ?? ['title', 'description', 'timeRestore'], search: query.text, perPage: query.limit, page: query.cursor ? +query.cursor : undefined, @@ -32,7 +49,16 @@ const searchArgsToSOFindOptions = ( }; }; -export class DashboardStorage extends SOContentStorage { +const savedObjectClientFromRequest = async (ctx: StorageContext) => { + if (!ctx.requestHandlerContext) { + throw new Error('Storage context.requestHandlerContext missing.'); + } + + const { savedObjects } = await ctx.requestHandlerContext.core; + return savedObjects.client; +}; + +export class DashboardStorage { constructor({ logger, throwOnResultValidationError, @@ -40,26 +66,316 @@ export class DashboardStorage extends SOContentStorage { logger: Logger; throwOnResultValidationError: boolean; }) { - super({ - savedObjectType: CONTENT_ID, - cmServicesDefinition, - searchArgsToSOFindOptions, - enableMSearch: true, - allowedSavedObjectAttributes: [ - 'kibanaSavedObjectMeta', - 'controlGroupInput', - 'refreshInterval', - 'description', - 'timeRestore', - 'optionsJSON', - 'panelsJSON', - 'timeFrom', - 'version', - 'timeTo', - 'title', - ], - logger, - throwOnResultValidationError, - }); + this.logger = logger; + this.throwOnResultValidationError = throwOnResultValidationError ?? false; + this.mSearch = { + savedObjectType: DASHBOARD_SAVED_OBJECT_TYPE, + additionalSearchFields: [], + toItemResult: (ctx: StorageContext, savedObject: SavedObjectsFindResult): DashboardItem => { + const transforms = ctx.utils.getTransforms(cmServicesDefinition); + + const { item, error: itemError } = savedObjectToItem( + savedObject as SavedObjectsFindResult, + false + ); + if (itemError) { + throw Boom.badRequest(`Invalid response. ${itemError.message}`); + } + + const validationError = transforms.mSearch.out.result.validate(item); + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // Validate DB response and DOWN transform to the request version + const { value, error: resultError } = transforms.mSearch.out.result.down< + DashboardItem, + DashboardItem + >( + item, + undefined, // do not override version + { validate: false } // validation is done above + ); + + if (resultError) { + throw Boom.badRequest(`Invalid response. ${resultError.message}`); + } + + return value; + }, + }; + } + + private logger: Logger; + private throwOnResultValidationError: boolean; + + mSearch: { + savedObjectType: string; + toItemResult: (ctx: StorageContext, savedObject: SavedObjectsFindResult) => DashboardItem; + additionalSearchFields?: string[]; + }; + + async get(ctx: StorageContext, id: string): Promise { + const transforms = ctx.utils.getTransforms(cmServicesDefinition); + const soClient = await savedObjectClientFromRequest(ctx); + + // Save data in DB + const { + saved_object: savedObject, + alias_purpose: aliasPurpose, + alias_target_id: aliasTargetId, + outcome, + } = await soClient.resolve(DASHBOARD_SAVED_OBJECT_TYPE, id); + + const { item, error: itemError } = savedObjectToItem(savedObject, false); + if (itemError) { + throw Boom.badRequest(`Invalid response. ${itemError.message}`); + } + + const response = { item, meta: { aliasPurpose, aliasTargetId, outcome } }; + + const validationError = transforms.get.out.result.validate(response); + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // Validate response and DOWN transform to the request version + const { value, error: resultError } = transforms.get.out.result.down< + DashboardGetOut, + DashboardGetOut + >( + response, + undefined, // do not override version + { validate: false } // validation is done above + ); + + if (resultError) { + throw Boom.badRequest(`Invalid response. ${resultError.message}`); + } + + return value; + } + + async bulkGet(): Promise { + // Not implemented + throw new Error(`[bulkGet] has not been implemented. See DashboardStorage class.`); + } + + async create( + ctx: StorageContext, + data: DashboardAttributes, + options: DashboardCreateOptions + ): Promise { + const transforms = ctx.utils.getTransforms(cmServicesDefinition); + const soClient = await savedObjectClientFromRequest(ctx); + + // Validate input (data & options) & UP transform them to the latest version + const { value: dataToLatest, error: dataError } = transforms.create.in.data.up< + DashboardAttributes, + DashboardAttributes + >(data); + if (dataError) { + throw Boom.badRequest(`Invalid data. ${dataError.message}`); + } + + const { value: optionsToLatest, error: optionsError } = transforms.create.in.options.up< + DashboardCreateOptions, + DashboardCreateOptions + >(options); + if (optionsError) { + throw Boom.badRequest(`Invalid options. ${optionsError.message}`); + } + + const { attributes: soAttributes, error: attributesError } = + itemAttrsToSavedObjectAttrs(dataToLatest); + if (attributesError) { + throw Boom.badRequest(`Invalid data. ${attributesError.message}`); + } + + // Save data in DB + const savedObject = await soClient.create( + DASHBOARD_SAVED_OBJECT_TYPE, + soAttributes, + optionsToLatest + ); + + const { item, error: itemError } = savedObjectToItem(savedObject, false); + if (itemError) { + throw Boom.badRequest(`Invalid response. ${itemError.message}`); + } + + const validationError = transforms.create.out.result.validate({ item }); + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // Validate DB response and DOWN transform to the request version + const { value, error: resultError } = transforms.create.out.result.down< + CreateResult + >( + { item }, + undefined, // do not override version + { validate: false } // validation is done above + ); + + if (resultError) { + throw Boom.badRequest(`Invalid response. ${resultError.message}`); + } + + return value; + } + + async update( + ctx: StorageContext, + id: string, + data: DashboardAttributes, + options: DashboardUpdateOptions + ): Promise { + const transforms = ctx.utils.getTransforms(cmServicesDefinition); + const soClient = await savedObjectClientFromRequest(ctx); + + // Validate input (data & options) & UP transform them to the latest version + const { value: dataToLatest, error: dataError } = transforms.update.in.data.up< + DashboardAttributes, + DashboardAttributes + >(data); + if (dataError) { + throw Boom.badRequest(`Invalid data. ${dataError.message}`); + } + + const { value: optionsToLatest, error: optionsError } = transforms.update.in.options.up< + DashboardUpdateOptions, + DashboardUpdateOptions + >(options); + if (optionsError) { + throw Boom.badRequest(`Invalid options. ${optionsError.message}`); + } + + const { attributes: soAttributes, error: attributesError } = + itemAttrsToSavedObjectAttrs(dataToLatest); + if (attributesError) { + throw Boom.badRequest(`Invalid data. ${attributesError.message}`); + } + + // Save data in DB + const partialSavedObject = await soClient.update( + DASHBOARD_SAVED_OBJECT_TYPE, + id, + soAttributes, + optionsToLatest + ); + + const { item, error: itemError } = savedObjectToItem(partialSavedObject, true); + if (itemError) { + throw Boom.badRequest(`Invalid response. ${itemError.message}`); + } + + const validationError = transforms.update.out.result.validate({ item }); + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // Validate DB response and DOWN transform to the request version + const { value, error: resultError } = transforms.update.out.result.down< + DashboardUpdateOut, + DashboardUpdateOut + >( + { item }, + undefined, // do not override version + { validate: false } // validation is done above + ); + + if (resultError) { + throw Boom.badRequest(`Invalid response. ${resultError.message}`); + } + + return value; + } + + async delete( + ctx: StorageContext, + id: string, + // force is necessary to delete saved objects that exist in multiple namespaces + options?: { force: boolean } + ): Promise { + const soClient = await savedObjectClientFromRequest(ctx); + await soClient.delete(DASHBOARD_SAVED_OBJECT_TYPE, id, { force: options?.force ?? false }); + return { success: true }; + } + + async search( + ctx: StorageContext, + query: SearchQuery, + options: DashboardSearchOptions + ): Promise { + const transforms = ctx.utils.getTransforms(cmServicesDefinition); + const soClient = await savedObjectClientFromRequest(ctx); + + // Validate and UP transform the options + const { value: optionsToLatest, error: optionsError } = transforms.search.in.options.up< + DashboardSearchOptions, + DashboardSearchOptions + >(options); + if (optionsError) { + throw Boom.badRequest(`Invalid payload. ${optionsError.message}`); + } + + const soQuery = searchArgsToSOFindOptions(query, optionsToLatest); + // Execute the query in the DB + const soResponse = await soClient.find(soQuery); + const hits = soResponse.saved_objects + .map((so) => { + const { item } = savedObjectToItem(so, false, soQuery.fields); + return item; + }) + // Ignore any saved objects that failed to convert to items. + .filter((item) => item !== null); + const response = { + hits, + pagination: { + total: soResponse.total, + }, + }; + + const validationError = transforms.search.out.result.validate(response); + if (validationError) { + if (this.throwOnResultValidationError) { + throw Boom.badRequest(`Invalid response. ${validationError.message}`); + } else { + this.logger.warn(`Invalid response. ${validationError.message}`); + } + } + + // Validate the response and DOWN transform to the request version + const { value, error: resultError } = transforms.search.out.result.down< + DashboardSearchOut, + DashboardSearchOut + >( + response, + undefined, // do not override version + { validate: false } // validation is done above + ); + + if (resultError) { + throw Boom.badRequest(`Invalid response. ${resultError.message}`); + } + + return value; } } diff --git a/src/plugins/dashboard/server/content_management/index.ts b/src/plugins/dashboard/server/content_management/index.ts index 653924191267..8ff43345aa9c 100644 --- a/src/plugins/dashboard/server/content_management/index.ts +++ b/src/plugins/dashboard/server/content_management/index.ts @@ -7,4 +7,24 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +export type { + ControlGroupAttributes, + GridData, + DashboardPanel, + DashboardAttributes, + DashboardItem, + DashboardGetIn, + DashboardGetOut, + DashboardCreateIn, + DashboardCreateOut, + DashboardCreateOptions, + DashboardSearchIn, + DashboardSearchOut, + DashboardSearchOptions, + DashboardUpdateIn, + DashboardUpdateOut, + DashboardUpdateOptions, + DashboardOptions, +} from './latest'; + export { DashboardStorage } from './dashboard_storage'; diff --git a/src/plugins/dashboard/common/content_management/latest.ts b/src/plugins/dashboard/server/content_management/latest.ts similarity index 91% rename from src/plugins/dashboard/common/content_management/latest.ts rename to src/plugins/dashboard/server/content_management/latest.ts index 82b84de84f8b..e35d4011f84f 100644 --- a/src/plugins/dashboard/common/content_management/latest.ts +++ b/src/plugins/dashboard/server/content_management/latest.ts @@ -7,5 +7,5 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -// Latest version is 2 -export * from './v2'; +// Latest version is 3 +export * from './v3'; diff --git a/src/plugins/dashboard/server/content_management/schema/v1/cm_services.ts b/src/plugins/dashboard/server/content_management/v1/cm_services.ts similarity index 61% rename from src/plugins/dashboard/server/content_management/schema/v1/cm_services.ts rename to src/plugins/dashboard/server/content_management/v1/cm_services.ts index f54cf0add822..0cee0bb23f45 100644 --- a/src/plugins/dashboard/server/content_management/schema/v1/cm_services.ts +++ b/src/plugins/dashboard/server/content_management/v1/cm_services.ts @@ -16,51 +16,7 @@ import { updateOptionsSchema, createResultSchema, } from '@kbn/content-management-utils'; - -export const controlGroupInputSchema = schema - .object({ - panelsJSON: schema.maybe(schema.string()), - controlStyle: schema.maybe(schema.string()), - chainingSystem: schema.maybe(schema.string()), - ignoreParentSettingsJSON: schema.maybe(schema.string()), - }) - .extends({}, { unknowns: 'ignore' }); - -export const dashboardAttributesSchema = schema.object( - { - // General - title: schema.string(), - description: schema.string({ defaultValue: '' }), - - // Search - kibanaSavedObjectMeta: schema.object({ - searchSourceJSON: schema.maybe(schema.string()), - }), - - // Time - timeRestore: schema.maybe(schema.boolean()), - timeFrom: schema.maybe(schema.string()), - timeTo: schema.maybe(schema.string()), - refreshInterval: schema.maybe( - schema.object({ - pause: schema.boolean(), - value: schema.number(), - display: schema.maybe(schema.string()), - section: schema.maybe(schema.number()), - }) - ), - - // Dashboard Content - controlGroupInput: schema.maybe(controlGroupInputSchema), - panelsJSON: schema.string({ defaultValue: '[]' }), - optionsJSON: schema.string({ defaultValue: '{}' }), - - // Legacy - hits: schema.maybe(schema.number()), - version: schema.maybe(schema.number()), - }, - { unknowns: 'forbid' } -); +import { dashboardAttributesSchema } from '../../dashboard_saved_object/schema/v1'; export const dashboardSavedObjectSchema = savedObjectSchema(dashboardAttributesSchema); @@ -84,8 +40,10 @@ const dashboardUpdateOptionsSchema = schema.object({ mergeAttributes: schema.maybe(updateOptionsSchema.mergeAttributes), }); -// Content management service definition. -// We need it for BWC support between different versions of the content +/** + * Content management service definition v1. + * Dashboard attributes in content management version v1 are tightly coupled with the v1 model version saved object schema. + */ export const serviceDefinition: ServicesDefinition = { get: { out: { diff --git a/src/plugins/dashboard/server/content_management/schema/v1/index.ts b/src/plugins/dashboard/server/content_management/v1/index.ts similarity index 77% rename from src/plugins/dashboard/server/content_management/schema/v1/index.ts rename to src/plugins/dashboard/server/content_management/v1/index.ts index c26552457e5f..163b952218bc 100644 --- a/src/plugins/dashboard/server/content_management/schema/v1/index.ts +++ b/src/plugins/dashboard/server/content_management/v1/index.ts @@ -7,9 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { - serviceDefinition, - dashboardSavedObjectSchema, - controlGroupInputSchema, - dashboardAttributesSchema, -} from './cm_services'; +export { serviceDefinition } from './cm_services'; diff --git a/src/plugins/dashboard/server/content_management/schema/v2/cm_services.ts b/src/plugins/dashboard/server/content_management/v2/cm_services.ts similarity index 70% rename from src/plugins/dashboard/server/content_management/schema/v2/cm_services.ts rename to src/plugins/dashboard/server/content_management/v2/cm_services.ts index 9e81945e4c71..3b560b841673 100644 --- a/src/plugins/dashboard/server/content_management/schema/v2/cm_services.ts +++ b/src/plugins/dashboard/server/content_management/v2/cm_services.ts @@ -7,36 +7,23 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { schema } from '@kbn/config-schema'; import { createResultSchema, objectTypeToGetResultSchema, savedObjectSchema, } from '@kbn/content-management-utils'; import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning'; -import { - controlGroupInputSchema as controlGroupInputSchemaV1, - dashboardAttributesSchema as dashboardAttributesSchemaV1, - serviceDefinition as serviceDefinitionV1, -} from '../v1'; - -export const dashboardAttributesSchema = dashboardAttributesSchemaV1.extends( - { - controlGroupInput: schema.maybe( - controlGroupInputSchemaV1.extends( - { - showApplySelections: schema.maybe(schema.boolean()), - }, - { unknowns: 'ignore' } - ) - ), - }, - { unknowns: 'ignore' } -); +import type { DashboardCrudTypes } from '../../../common/content_management/v2'; +import { serviceDefinition as serviceDefinitionV1 } from '../v1'; +import { dashboardAttributesOut as attributesTov3 } from '../v3'; +import { dashboardAttributesSchema } from '../../dashboard_saved_object/schema/v2'; export const dashboardSavedObjectSchema = savedObjectSchema(dashboardAttributesSchema); -// Content management service definition. +/** + * Content management service definition v2. + * Dashboard attributes in content management version v2 are tightly coupled with the v2 model version saved object schema. + */ export const serviceDefinition: ServicesDefinition = { get: { out: { @@ -50,6 +37,7 @@ export const serviceDefinition: ServicesDefinition = { ...serviceDefinitionV1?.create?.in, data: { schema: dashboardAttributesSchema, + up: (data: DashboardCrudTypes['CreateIn']['data']) => attributesTov3(data), }, }, out: { @@ -63,6 +51,7 @@ export const serviceDefinition: ServicesDefinition = { ...serviceDefinitionV1.update?.in, data: { schema: dashboardAttributesSchema, + up: (data: DashboardCrudTypes['UpdateIn']['data']) => attributesTov3(data), }, }, }, diff --git a/src/plugins/dashboard/server/content_management/v2/index.ts b/src/plugins/dashboard/server/content_management/v2/index.ts new file mode 100644 index 000000000000..163b952218bc --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v2/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { serviceDefinition } from './cm_services'; diff --git a/src/plugins/dashboard/server/content_management/v3/cm_services.ts b/src/plugins/dashboard/server/content_management/v3/cm_services.ts new file mode 100644 index 000000000000..e086d1cc1460 --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v3/cm_services.ts @@ -0,0 +1,539 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { v4 as uuidv4 } from 'uuid'; +import { schema, Type } from '@kbn/config-schema'; +import { createOptionsSchemas, updateOptionsSchema } from '@kbn/content-management-utils'; +import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning'; +import { + type ControlGroupChainingSystem, + type ControlLabelPosition, + type ControlWidth, + CONTROL_CHAINING_OPTIONS, + CONTROL_LABEL_POSITION_OPTIONS, + CONTROL_WIDTH_OPTIONS, + DEFAULT_CONTROL_CHAINING, + DEFAULT_CONTROL_GROW, + DEFAULT_CONTROL_LABEL_POSITION, + DEFAULT_CONTROL_WIDTH, + DEFAULT_IGNORE_PARENT_SETTINGS, + DEFAULT_AUTO_APPLY_SELECTIONS, +} from '@kbn/controls-plugin/common'; +import { FilterStateStore } from '@kbn/es-query'; +import { SortDirection } from '@kbn/data-plugin/common/search'; +import { + DASHBOARD_GRID_COLUMN_COUNT, + DEFAULT_PANEL_HEIGHT, + DEFAULT_PANEL_WIDTH, + DEFAULT_DASHBOARD_OPTIONS, +} from '../../../common/content_management'; +import { getResultV3ToV2 } from './transform_utils'; + +const apiError = schema.object({ + error: schema.string(), + message: schema.string(), + statusCode: schema.number(), + metadata: schema.maybe(schema.object({}, { unknowns: 'allow' })), +}); + +// This schema should be provided by the controls plugin. Perhaps we can resolve this with the embeddable registry. +// See https://github.com/elastic/kibana/issues/192622 +export const controlGroupInputSchema = schema.object({ + controls: schema.arrayOf( + schema.object( + { + type: schema.string({ meta: { description: 'The type of the control panel.' } }), + controlConfig: schema.maybe(schema.recordOf(schema.string(), schema.any())), + id: schema.string({ + defaultValue: uuidv4(), + meta: { description: 'The unique ID of the control.' }, + }), + order: schema.number({ + meta: { + description: 'The order of the control panel in the control group.', + }, + }), + width: schema.oneOf( + Object.values(CONTROL_WIDTH_OPTIONS).map((value) => schema.literal(value)) as [ + Type + ], + { + defaultValue: DEFAULT_CONTROL_WIDTH, + meta: { description: 'Minimum width of the control panel in the control group.' }, + } + ), + grow: schema.boolean({ + defaultValue: DEFAULT_CONTROL_GROW, + meta: { description: 'Expand width of the control panel to fit available space.' }, + }), + }, + { unknowns: 'allow' } + ), + { + defaultValue: [], + meta: { description: 'An array of control panels and their state in the control group.' }, + } + ), + labelPosition: schema.oneOf( + Object.values(CONTROL_LABEL_POSITION_OPTIONS).map((value) => schema.literal(value)) as [ + Type + ], + { + defaultValue: DEFAULT_CONTROL_LABEL_POSITION, + meta: { + description: 'Position of the labels for controls. For example, "oneLine", "twoLine".', + }, + } + ), + chainingSystem: schema.oneOf( + Object.values(CONTROL_CHAINING_OPTIONS).map((value) => schema.literal(value)) as [ + Type + ], + { + defaultValue: DEFAULT_CONTROL_CHAINING, + meta: { + description: + 'The chaining strategy for multiple controls. For example, "HIERARCHICAL" or "NONE".', + }, + } + ), + enhancements: schema.maybe(schema.recordOf(schema.string(), schema.any())), + ignoreParentSettings: schema.object({ + ignoreFilters: schema.boolean({ + meta: { description: 'Ignore global filters in controls.' }, + defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreFilters, + }), + ignoreQuery: schema.boolean({ + meta: { description: 'Ignore the global query bar in controls.' }, + defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreQuery, + }), + ignoreTimerange: schema.boolean({ + meta: { description: 'Ignore the global time range in controls.' }, + defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreTimerange, + }), + ignoreValidations: schema.boolean({ + meta: { description: 'Ignore validations in controls.' }, + defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreValidations, + }), + }), + autoApplySelections: schema.boolean({ + meta: { description: 'Show apply selections button in controls.' }, + defaultValue: DEFAULT_AUTO_APPLY_SELECTIONS, + }), +}); + +const searchSourceSchema = schema.object( + { + type: schema.maybe(schema.string()), + query: schema.maybe( + schema.object({ + query: schema.oneOf([ + schema.string({ + meta: { + description: + 'A text-based query such as Kibana Query Language (KQL) or Lucene query language.', + }, + }), + schema.recordOf(schema.string(), schema.any()), + ]), + language: schema.string({ + meta: { description: 'The query language such as KQL or Lucene.' }, + }), + }) + ), + filter: schema.maybe( + schema.arrayOf( + schema.object( + { + meta: schema.object( + { + alias: schema.maybe(schema.nullable(schema.string())), + disabled: schema.maybe(schema.boolean()), + negate: schema.maybe(schema.boolean()), + controlledBy: schema.maybe(schema.string()), + group: schema.maybe(schema.string()), + index: schema.maybe(schema.string()), + isMultiIndex: schema.maybe(schema.boolean()), + type: schema.maybe(schema.string()), + key: schema.maybe(schema.string()), + params: schema.maybe(schema.any()), + value: schema.maybe(schema.string()), + field: schema.maybe(schema.string()), + }, + { unknowns: 'allow' } + ), + query: schema.maybe(schema.recordOf(schema.string(), schema.any())), + $state: schema.maybe( + schema.object({ + store: schema.oneOf( + [ + schema.literal(FilterStateStore.APP_STATE), + schema.literal(FilterStateStore.GLOBAL_STATE), + ], + { + meta: { + description: + "Denote whether a filter is specific to an application's context (e.g. 'appState') or whether it should be applied globally (e.g. 'globalState').", + }, + } + ), + }) + ), + }, + { meta: { description: 'A filter for the search source.' } } + ) + ) + ), + sort: schema.maybe( + schema.arrayOf( + schema.recordOf( + schema.string(), + schema.oneOf([ + schema.oneOf([schema.literal(SortDirection.asc), schema.literal(SortDirection.desc)]), + schema.object({ + order: schema.oneOf([ + schema.literal(SortDirection.asc), + schema.literal(SortDirection.desc), + ]), + format: schema.maybe(schema.string()), + }), + schema.object({ + order: schema.oneOf([ + schema.literal(SortDirection.asc), + schema.literal(SortDirection.desc), + ]), + numeric_type: schema.maybe( + schema.oneOf([ + schema.literal('double'), + schema.literal('long'), + schema.literal('date'), + schema.literal('date_nanos'), + ]) + ), + }), + ]) + ) + ) + ), + }, + /** + The Dashboard _should_ only ever uses the query and filters fields on the search + source. But we should be liberal in what we accept, so we allow unknowns. + */ + { defaultValue: {}, unknowns: 'allow' } +); + +export const gridDataSchema = schema.object({ + x: schema.number({ meta: { description: 'The x coordinate of the panel in grid units' } }), + y: schema.number({ meta: { description: 'The y coordinate of the panel in grid units' } }), + w: schema.number({ + defaultValue: DEFAULT_PANEL_WIDTH, + min: 1, + max: DASHBOARD_GRID_COLUMN_COUNT, + meta: { description: 'The width of the panel in grid units' }, + }), + h: schema.number({ + defaultValue: DEFAULT_PANEL_HEIGHT, + min: 1, + meta: { description: 'The height of the panel in grid units' }, + }), + i: schema.string({ + meta: { description: 'The unique identifier of the panel' }, + defaultValue: uuidv4(), + }), +}); + +export const panelSchema = schema.object({ + panelConfig: schema.object( + { + version: schema.maybe( + schema.string({ + meta: { description: 'The version of the embeddable in the panel.' }, + }) + ), + title: schema.maybe(schema.string({ meta: { description: 'The title of the panel' } })), + description: schema.maybe( + schema.string({ meta: { description: 'The description of the panel' } }) + ), + savedObjectId: schema.maybe( + schema.string({ + meta: { description: 'The unique id of the library item to construct the embeddable.' }, + }) + ), + hidePanelTitles: schema.maybe( + schema.boolean({ + defaultValue: false, + meta: { description: 'Set to true to hide the panel title in its container.' }, + }) + ), + enhancements: schema.maybe(schema.recordOf(schema.string(), schema.any())), + }, + { + unknowns: 'allow', + } + ), + id: schema.maybe( + schema.string({ meta: { description: 'The saved object id for by reference panels' } }) + ), + type: schema.string({ meta: { description: 'The embeddable type' } }), + panelRefName: schema.maybe(schema.string()), + gridData: gridDataSchema, + panelIndex: schema.string({ + meta: { description: 'The unique ID of the panel.' }, + defaultValue: schema.siblingRef('gridData.i'), + }), + title: schema.maybe(schema.string({ meta: { description: 'The title of the panel' } })), + version: schema.maybe( + schema.string({ + meta: { + description: + "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).", + deprecated: true, + }, + }) + ), +}); + +export const optionsSchema = schema.object({ + hidePanelTitles: schema.boolean({ + defaultValue: DEFAULT_DASHBOARD_OPTIONS.hidePanelTitles, + meta: { description: 'Hide the panel titles in the dashboard.' }, + }), + useMargins: schema.boolean({ + defaultValue: DEFAULT_DASHBOARD_OPTIONS.useMargins, + meta: { description: 'Show margins between panels in the dashboard layout.' }, + }), + syncColors: schema.boolean({ + defaultValue: DEFAULT_DASHBOARD_OPTIONS.syncColors, + meta: { description: 'Synchronize colors between related panels in the dashboard.' }, + }), + syncTooltips: schema.boolean({ + defaultValue: DEFAULT_DASHBOARD_OPTIONS.syncTooltips, + meta: { description: 'Synchronize tooltips between related panels in the dashboard.' }, + }), + syncCursor: schema.boolean({ + defaultValue: DEFAULT_DASHBOARD_OPTIONS.syncCursor, + meta: { description: 'Synchronize cursor position between related panels in the dashboard.' }, + }), +}); + +// These are the attributes that are returned in search results +export const searchResultsAttributesSchema = schema.object({ + title: schema.string({ meta: { description: 'A human-readable title for the dashboard' } }), + description: schema.string({ defaultValue: '', meta: { description: 'A short description.' } }), + timeRestore: schema.boolean({ + defaultValue: false, + meta: { description: 'Whether to restore time upon viewing this dashboard' }, + }), +}); + +export const dashboardAttributesSchema = searchResultsAttributesSchema.extends({ + // Search + kibanaSavedObjectMeta: schema.object( + { + searchSource: schema.maybe(searchSourceSchema), + }, + { + meta: { + description: 'A container for various metadata', + }, + defaultValue: {}, + } + ), + // Time + timeFrom: schema.maybe( + schema.string({ meta: { description: 'An ISO string indicating when to restore time from' } }) + ), + timeTo: schema.maybe( + schema.string({ meta: { description: 'An ISO string indicating when to restore time from' } }) + ), + refreshInterval: schema.maybe( + schema.object( + { + pause: schema.boolean({ + meta: { + description: + 'Whether the refresh interval is set to be paused while viewing the dashboard.', + }, + }), + value: schema.number({ + meta: { + description: 'A numeric value indicating refresh frequency in milliseconds.', + }, + }), + display: schema.maybe( + schema.string({ + meta: { + description: + 'A human-readable string indicating the refresh frequency. No longer used.', + deprecated: true, + }, + }) + ), + section: schema.maybe( + schema.number({ + meta: { + description: 'No longer used.', // TODO what is this legacy property? + deprecated: true, + }, + }) + ), + }, + { + meta: { + description: 'A container for various refresh interval settings', + }, + } + ) + ), + + // Dashboard Content + controlGroupInput: schema.maybe(controlGroupInputSchema), + panels: schema.arrayOf(panelSchema, { defaultValue: [] }), + options: optionsSchema, + version: schema.maybe(schema.number({ meta: { deprecated: true } })), +}); + +export const referenceSchema = schema.object( + { + name: schema.string(), + type: schema.string(), + id: schema.string(), + }, + { unknowns: 'forbid' } +); + +export const dashboardItemSchema = schema.object( + { + id: schema.string(), + type: schema.string(), + version: schema.maybe(schema.string()), + createdAt: schema.maybe(schema.string()), + updatedAt: schema.maybe(schema.string()), + createdBy: schema.maybe(schema.string()), + updatedBy: schema.maybe(schema.string()), + managed: schema.maybe(schema.boolean()), + error: schema.maybe(apiError), + attributes: dashboardAttributesSchema, + references: schema.arrayOf(referenceSchema), + namespaces: schema.maybe(schema.arrayOf(schema.string())), + originId: schema.maybe(schema.string()), + }, + { unknowns: 'allow' } +); + +export const dashboardSearchResultsSchema = dashboardItemSchema.extends({ + attributes: searchResultsAttributesSchema, +}); + +export const dashboardSearchOptionsSchema = schema.maybe( + schema.object( + { + onlyTitle: schema.maybe(schema.boolean()), + fields: schema.maybe(schema.arrayOf(schema.string())), + kuery: schema.maybe(schema.string()), + cursor: schema.maybe(schema.number()), + limit: schema.maybe(schema.number()), + }, + { unknowns: 'forbid' } + ) +); + +export const dashboardCreateOptionsSchema = schema.object({ + id: schema.maybe(createOptionsSchemas.id), + overwrite: schema.maybe(createOptionsSchemas.overwrite), + references: schema.maybe(schema.arrayOf(referenceSchema)), + initialNamespaces: schema.maybe(createOptionsSchemas.initialNamespaces), +}); + +export const dashboardUpdateOptionsSchema = schema.object({ + references: schema.maybe(schema.arrayOf(referenceSchema)), + mergeAttributes: schema.maybe(updateOptionsSchema.mergeAttributes), +}); + +export const dashboardGetResultSchema = schema.object( + { + item: dashboardItemSchema, + meta: schema.object( + { + outcome: schema.oneOf([ + schema.literal('exactMatch'), + schema.literal('aliasMatch'), + schema.literal('conflict'), + ]), + aliasTargetId: schema.maybe(schema.string()), + aliasPurpose: schema.maybe( + schema.oneOf([ + schema.literal('savedObjectConversion'), + schema.literal('savedObjectImport'), + ]) + ), + }, + { unknowns: 'forbid' } + ), + }, + { unknowns: 'forbid' } +); + +export const dashboardCreateResultSchema = schema.object( + { + item: dashboardItemSchema, + }, + { unknowns: 'forbid' } +); + +export const serviceDefinition: ServicesDefinition = { + get: { + out: { + result: { + schema: dashboardGetResultSchema, + down: getResultV3ToV2, + }, + }, + }, + create: { + in: { + options: { + schema: dashboardCreateOptionsSchema, + }, + data: { + schema: dashboardAttributesSchema, + }, + }, + out: { + result: { + schema: dashboardCreateResultSchema, + }, + }, + }, + update: { + in: { + options: { + schema: dashboardUpdateOptionsSchema, + }, + data: { + schema: dashboardAttributesSchema, + }, + }, + }, + search: { + in: { + options: { + schema: dashboardSearchOptionsSchema, + }, + }, + }, + mSearch: { + out: { + result: { + schema: dashboardItemSchema, + }, + }, + }, +}; diff --git a/src/plugins/dashboard/server/content_management/v3/index.ts b/src/plugins/dashboard/server/content_management/v3/index.ts new file mode 100644 index 000000000000..7be9313c3210 --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v3/index.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { + ControlGroupAttributes, + GridData, + DashboardPanel, + DashboardAttributes, + DashboardItem, + DashboardGetIn, + DashboardGetOut, + DashboardCreateIn, + DashboardCreateOut, + DashboardCreateOptions, + DashboardSearchIn, + DashboardSearchOut, + DashboardSearchOptions, + DashboardUpdateIn, + DashboardUpdateOut, + DashboardUpdateOptions, + DashboardOptions, +} from './types'; +export { + serviceDefinition, + dashboardAttributesSchema, + dashboardGetResultSchema, + dashboardCreateResultSchema, + dashboardItemSchema, + dashboardSearchResultsSchema, + referenceSchema, +} from './cm_services'; +export { + dashboardAttributesOut, + itemAttrsToSavedObjectAttrs, + savedObjectToItem, +} from './transform_utils'; diff --git a/src/plugins/dashboard/server/content_management/v3/transform_utils.test.ts b/src/plugins/dashboard/server/content_management/v3/transform_utils.test.ts new file mode 100644 index 000000000000..627f1c421103 --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v3/transform_utils.test.ts @@ -0,0 +1,551 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { SavedObject } from '@kbn/core-saved-objects-api-server'; +import type { + DashboardSavedObjectAttributes, + SavedDashboardPanel, +} from '../../dashboard_saved_object'; +import type { DashboardAttributes, DashboardItem } from './types'; +import { + dashboardAttributesOut, + getResultV3ToV2, + itemAttrsToSavedObjectAttrs, + savedObjectToItem, +} from './transform_utils'; +import { + DEFAULT_AUTO_APPLY_SELECTIONS, + DEFAULT_CONTROL_CHAINING, + DEFAULT_CONTROL_GROW, + DEFAULT_CONTROL_LABEL_POSITION, + DEFAULT_CONTROL_WIDTH, + DEFAULT_IGNORE_PARENT_SETTINGS, + ControlLabelPosition, + ControlGroupChainingSystem, + ControlWidth, +} from '@kbn/controls-plugin/common'; +import { DEFAULT_DASHBOARD_OPTIONS } from '../../../common/content_management'; + +describe('dashboardAttributesOut', () => { + const controlGroupInputControlsSo = { + explicitInput: { anyKey: 'some value' }, + type: 'type1', + order: 0, + }; + + const panelsSo: SavedDashboardPanel[] = [ + { + embeddableConfig: { enhancements: {} }, + gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' }, + id: '1', + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ]; + + it('should set default values if not provided', () => { + const input: DashboardSavedObjectAttributes = { + controlGroupInput: { + panelsJSON: JSON.stringify({ foo: controlGroupInputControlsSo }), + }, + panelsJSON: JSON.stringify(panelsSo), + optionsJSON: JSON.stringify({ + hidePanelTitles: false, + }), + kibanaSavedObjectMeta: {}, + title: 'my title', + description: 'my description', + }; + expect(dashboardAttributesOut(input)).toEqual({ + controlGroupInput: { + chainingSystem: DEFAULT_CONTROL_CHAINING, + labelPosition: DEFAULT_CONTROL_LABEL_POSITION, + ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS, + autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS, + controls: [ + { + controlConfig: { anyKey: 'some value' }, + grow: DEFAULT_CONTROL_GROW, + id: 'foo', + order: 0, + type: 'type1', + width: DEFAULT_CONTROL_WIDTH, + }, + ], + }, + description: 'my description', + kibanaSavedObjectMeta: {}, + options: { + ...DEFAULT_DASHBOARD_OPTIONS, + hidePanelTitles: false, + }, + panels: [ + { + panelConfig: { enhancements: {} }, + gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' }, + id: '1', + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ], + timeRestore: false, + title: 'my title', + }); + }); + + it('should transform full attributes correctly', () => { + const input: DashboardSavedObjectAttributes = { + controlGroupInput: { + panelsJSON: JSON.stringify({ + foo: { + ...controlGroupInputControlsSo, + grow: false, + width: 'small', + }, + }), + ignoreParentSettingsJSON: JSON.stringify({ ignoreFilters: true }), + controlStyle: 'twoLine', + chainingSystem: 'NONE', + showApplySelections: true, + }, + description: 'description', + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ query: { query: 'test', language: 'KQL' } }), + }, + optionsJSON: JSON.stringify({ + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncTooltips: false, + syncCursor: false, + }), + panelsJSON: JSON.stringify(panelsSo), + refreshInterval: { pause: true, value: 1000 }, + timeFrom: 'now-15m', + timeRestore: true, + timeTo: 'now', + title: 'title', + }; + expect(dashboardAttributesOut(input)).toEqual({ + controlGroupInput: { + chainingSystem: 'NONE', + labelPosition: 'twoLine', + ignoreParentSettings: { + ignoreFilters: true, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, + autoApplySelections: false, + controls: [ + { + controlConfig: { + anyKey: 'some value', + }, + id: 'foo', + grow: false, + width: 'small', + order: 0, + type: 'type1', + }, + ], + }, + description: 'description', + kibanaSavedObjectMeta: { + searchSource: { query: { query: 'test', language: 'KQL' } }, + }, + options: { + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncTooltips: false, + syncCursor: false, + }, + panels: [ + { + panelConfig: { + enhancements: {}, + }, + gridData: { + x: 0, + y: 0, + w: 10, + h: 10, + i: '1', + }, + id: '1', + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ], + refreshInterval: { + pause: true, + value: 1000, + }, + timeFrom: 'now-15m', + timeRestore: true, + timeTo: 'now', + title: 'title', + }); + }); +}); + +describe('itemAttrsToSavedObjectAttrs', () => { + it('should transform item attributes to saved object attributes correctly', () => { + const input: DashboardAttributes = { + controlGroupInput: { + chainingSystem: 'NONE', + labelPosition: 'twoLine', + controls: [ + { + controlConfig: { anyKey: 'some value' }, + grow: false, + id: 'foo', + order: 0, + type: 'type1', + width: 'small', + }, + ], + ignoreParentSettings: { + ignoreFilters: true, + ignoreQuery: true, + ignoreTimerange: true, + ignoreValidations: true, + }, + autoApplySelections: false, + }, + description: 'description', + kibanaSavedObjectMeta: { searchSource: { query: { query: 'test', language: 'KQL' } } }, + options: { + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncTooltips: false, + syncCursor: false, + }, + panels: [ + { + gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' }, + id: '1', + panelConfig: { enhancements: {} }, + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ], + timeRestore: true, + title: 'title', + refreshInterval: { pause: true, value: 1000 }, + timeFrom: 'now-15m', + timeTo: 'now', + }; + + const output = itemAttrsToSavedObjectAttrs(input); + expect(output).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "controlGroupInput": Object { + "chainingSystem": "NONE", + "controlStyle": "twoLine", + "ignoreParentSettingsJSON": "{\\"ignoreFilters\\":true,\\"ignoreQuery\\":true,\\"ignoreTimerange\\":true,\\"ignoreValidations\\":true}", + "panelsJSON": "{\\"foo\\":{\\"grow\\":false,\\"order\\":0,\\"type\\":\\"type1\\",\\"width\\":\\"small\\",\\"explicitInput\\":{\\"anyKey\\":\\"some value\\",\\"id\\":\\"foo\\"}}}", + "showApplySelections": true, + }, + "description": "description", + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"query\\":{\\"query\\":\\"test\\",\\"language\\":\\"KQL\\"}}", + }, + "optionsJSON": "{\\"hidePanelTitles\\":true,\\"useMargins\\":false,\\"syncColors\\":false,\\"syncTooltips\\":false,\\"syncCursor\\":false}", + "panelsJSON": "[{\\"id\\":\\"1\\",\\"panelRefName\\":\\"ref1\\",\\"title\\":\\"title1\\",\\"type\\":\\"type1\\",\\"version\\":\\"2\\",\\"embeddableConfig\\":{\\"enhancements\\":{}},\\"panelIndex\\":\\"1\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":10,\\"h\\":10,\\"i\\":\\"1\\"}}]", + "refreshInterval": Object { + "pause": true, + "value": 1000, + }, + "timeFrom": "now-15m", + "timeRestore": true, + "timeTo": "now", + "title": "title", + }, + "error": null, + } + `); + }); + + it('should handle missing optional attributes', () => { + const input: DashboardAttributes = { + title: 'title', + description: 'my description', + timeRestore: false, + panels: [], + options: DEFAULT_DASHBOARD_OPTIONS, + kibanaSavedObjectMeta: {}, + }; + + const output = itemAttrsToSavedObjectAttrs(input); + expect(output).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "description": "my description", + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{}", + }, + "optionsJSON": "{\\"hidePanelTitles\\":false,\\"useMargins\\":true,\\"syncColors\\":true,\\"syncCursor\\":true,\\"syncTooltips\\":true}", + "panelsJSON": "[]", + "timeRestore": false, + "title": "title", + }, + "error": null, + } + `); + }); +}); + +describe('savedObjectToItem', () => { + const commonSavedObject: SavedObject = { + references: [], + id: '3d8459d9-0f1a-403d-aa82-6d93713a54b5', + type: 'dashboard', + attributes: {}, + }; + + const getSavedObjectForAttributes = ( + attributes: DashboardSavedObjectAttributes + ): SavedObject => { + return { + ...commonSavedObject, + attributes, + }; + }; + it('should convert saved object to item with all attributes', () => { + const input = getSavedObjectForAttributes({ + title: 'title', + description: 'description', + timeRestore: true, + panelsJSON: JSON.stringify([ + { + embeddableConfig: { enhancements: {} }, + gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' }, + id: '1', + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ]), + optionsJSON: JSON.stringify({ + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncTooltips: false, + syncCursor: false, + }), + kibanaSavedObjectMeta: { + searchSourceJSON: '{"query":{"query":"test","language":"KQL"}}', + }, + }); + + const { item, error } = savedObjectToItem(input, false); + expect(error).toBeNull(); + expect(item).toEqual({ + ...commonSavedObject, + attributes: { + title: 'title', + description: 'description', + timeRestore: true, + panels: [ + { + panelConfig: { enhancements: {} }, + gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' }, + id: '1', + panelIndex: '1', + panelRefName: 'ref1', + title: 'title1', + type: 'type1', + version: '2', + }, + ], + options: { + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncTooltips: false, + syncCursor: false, + }, + kibanaSavedObjectMeta: { + searchSource: { query: { query: 'test', language: 'KQL' } }, + }, + }, + }); + }); + + it('should handle missing optional attributes', () => { + const input = getSavedObjectForAttributes({ + title: 'title', + description: 'description', + timeRestore: false, + panelsJSON: '[]', + optionsJSON: '{}', + kibanaSavedObjectMeta: {}, + }); + + const { item, error } = savedObjectToItem(input, false); + expect(error).toBeNull(); + expect(item).toEqual({ + ...commonSavedObject, + attributes: { + title: 'title', + description: 'description', + timeRestore: false, + panels: [], + options: DEFAULT_DASHBOARD_OPTIONS, + kibanaSavedObjectMeta: {}, + }, + }); + }); + + it('should handle partial saved object', () => { + const input = { + ...commonSavedObject, + references: undefined, + attributes: { + title: 'title', + description: 'my description', + timeRestore: false, + }, + }; + + const { item, error } = savedObjectToItem(input, true, ['title', 'description']); + expect(error).toBeNull(); + expect(item).toEqual({ + ...commonSavedObject, + references: undefined, + attributes: { + title: 'title', + description: 'my description', + }, + }); + }); + + it('should return an error if attributes can not be parsed', () => { + const input = { + ...commonSavedObject, + references: undefined, + attributes: { + title: 'title', + panelsJSON: 'not stringified json', + }, + }; + const { item, error } = savedObjectToItem(input, true); + expect(item).toBeNull(); + expect(error).not.toBe(null); + }); +}); + +describe('getResultV3ToV2', () => { + const commonAttributes = { + description: 'description', + refreshInterval: { pause: true, value: 1000 }, + timeFrom: 'now-15m', + timeRestore: true, + timeTo: 'now', + title: 'title', + }; + it('should transform a v3 result to a v2 result with all attributes', () => { + const v3Result = { + meta: { outcome: 'exactMatch' as const }, + item: { + id: '1', + type: 'dashboard', + attributes: { + ...commonAttributes, + controlGroupInput: { + chainingSystem: 'NONE' as ControlGroupChainingSystem, + labelPosition: 'twoLine' as ControlLabelPosition, + controls: [ + { + controlConfig: { bizz: 'buzz' }, + grow: false, + order: 0, + id: 'foo', + type: 'type1', + width: 'small' as ControlWidth, + }, + ], + ignoreParentSettings: { + ignoreFilters: true, + ignoreQuery: true, + ignoreTimerange: true, + ignoreValidations: true, + }, + autoApplySelections: false, + }, + kibanaSavedObjectMeta: { searchSource: { query: { query: 'test', language: 'KQL' } } }, + options: { + hidePanelTitles: true, + useMargins: false, + syncColors: false, + syncCursor: false, + syncTooltips: false, + }, + panels: [ + { + id: '1', + type: 'visualization', + panelConfig: { title: 'my panel' }, + gridData: { x: 0, y: 0, w: 15, h: 15, i: 'foo' }, + panelIndex: 'foo', + }, + ], + }, + references: [], + }, + }; + + const output = getResultV3ToV2(v3Result); + + // Common attributes should match between v2 and v3 + expect(output.item.attributes).toMatchObject(commonAttributes); + expect(output.item.attributes.controlGroupInput).toMatchObject({ + chainingSystem: 'NONE', + controlStyle: 'twoLine', + showApplySelections: true, + }); + + // Check transformed attributes + expect(output.item.attributes.controlGroupInput!.panelsJSON).toMatchInlineSnapshot( + `"{\\"foo\\":{\\"grow\\":false,\\"order\\":0,\\"type\\":\\"type1\\",\\"width\\":\\"small\\",\\"explicitInput\\":{\\"bizz\\":\\"buzz\\",\\"id\\":\\"foo\\"}}}"` + ); + expect( + output.item.attributes.controlGroupInput!.ignoreParentSettingsJSON + ).toMatchInlineSnapshot( + `"{\\"ignoreFilters\\":true,\\"ignoreQuery\\":true,\\"ignoreTimerange\\":true,\\"ignoreValidations\\":true}"` + ); + expect(output.item.attributes.kibanaSavedObjectMeta.searchSourceJSON).toMatchInlineSnapshot( + `"{\\"query\\":{\\"query\\":\\"test\\",\\"language\\":\\"KQL\\"}}"` + ); + expect(output.item.attributes.optionsJSON).toMatchInlineSnapshot( + `"{\\"hidePanelTitles\\":true,\\"useMargins\\":false,\\"syncColors\\":false,\\"syncCursor\\":false,\\"syncTooltips\\":false}"` + ); + expect(output.item.attributes.panelsJSON).toMatchInlineSnapshot( + `"[{\\"id\\":\\"1\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{\\"title\\":\\"my panel\\"},\\"panelIndex\\":\\"foo\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":15,\\"h\\":15,\\"i\\":\\"foo\\"}}]"` + ); + }); +}); diff --git a/src/plugins/dashboard/server/content_management/v3/transform_utils.ts b/src/plugins/dashboard/server/content_management/v3/transform_utils.ts new file mode 100644 index 000000000000..843dd59f849f --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v3/transform_utils.ts @@ -0,0 +1,365 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { v4 as uuidv4 } from 'uuid'; +import { pick } from 'lodash'; + +import type { Query } from '@kbn/es-query'; +import { + type ControlGroupChainingSystem, + type ControlLabelPosition, + type ControlPanelsState, + type SerializedControlState, + DEFAULT_AUTO_APPLY_SELECTIONS, + DEFAULT_CONTROL_CHAINING, + DEFAULT_CONTROL_GROW, + DEFAULT_CONTROL_LABEL_POSITION, + DEFAULT_CONTROL_WIDTH, + DEFAULT_IGNORE_PARENT_SETTINGS, +} from '@kbn/controls-plugin/common'; +import { SerializedSearchSourceFields, parseSearchSourceJSON } from '@kbn/data-plugin/common'; + +import type { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-api-server'; +import type { + ControlGroupAttributes, + DashboardAttributes, + DashboardGetOut, + DashboardItem, + DashboardOptions, + ItemAttrsToSavedObjectAttrsReturn, + PartialDashboardItem, + SavedObjectToItemReturn, +} from './types'; +import type { + DashboardSavedObjectAttributes, + SavedDashboardPanel, +} from '../../dashboard_saved_object'; +import type { + ControlGroupAttributes as ControlGroupAttributesV2, + DashboardCrudTypes as DashboardCrudTypesV2, +} from '../../../common/content_management/v2'; +import { DEFAULT_DASHBOARD_OPTIONS } from '../../../common/content_management'; + +function controlGroupInputOut( + controlGroupInput?: DashboardSavedObjectAttributes['controlGroupInput'] +): ControlGroupAttributes | undefined { + if (!controlGroupInput) { + return; + } + const { + panelsJSON, + ignoreParentSettingsJSON, + controlStyle = DEFAULT_CONTROL_LABEL_POSITION, + chainingSystem = DEFAULT_CONTROL_CHAINING, + showApplySelections = !DEFAULT_AUTO_APPLY_SELECTIONS, + } = controlGroupInput; + const controls = panelsJSON + ? Object.entries(JSON.parse(panelsJSON) as ControlPanelsState).map( + ([ + id, + { + explicitInput, + type, + grow = DEFAULT_CONTROL_GROW, + width = DEFAULT_CONTROL_WIDTH, + order, + }, + ]) => ({ + controlConfig: explicitInput, + id, + grow, + order, + type, + width, + }) + ) + : []; + + const { + ignoreFilters = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreFilters, + ignoreQuery = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreQuery, + ignoreTimerange = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreTimerange, + ignoreValidations = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreValidations, + } = ignoreParentSettingsJSON ? JSON.parse(ignoreParentSettingsJSON) : {}; + + // try to maintain a consistent (alphabetical) order of keys + return { + autoApplySelections: !showApplySelections, + chainingSystem: chainingSystem as ControlGroupChainingSystem, + controls, + labelPosition: controlStyle as ControlLabelPosition, + ignoreParentSettings: { ignoreFilters, ignoreQuery, ignoreTimerange, ignoreValidations }, + }; +} + +function kibanaSavedObjectMetaOut( + kibanaSavedObjectMeta: DashboardSavedObjectAttributes['kibanaSavedObjectMeta'] +): DashboardAttributes['kibanaSavedObjectMeta'] { + const { searchSourceJSON } = kibanaSavedObjectMeta; + if (!searchSourceJSON) { + return {}; + } + // Dashboards do not yet support ES|QL (AggregateQuery) in the search source + return { + searchSource: parseSearchSourceJSON(searchSourceJSON) as Omit< + SerializedSearchSourceFields, + 'query' + > & { query?: Query }, + }; +} + +function optionsOut(optionsJSON: string): DashboardAttributes['options'] { + const { + hidePanelTitles = DEFAULT_DASHBOARD_OPTIONS.hidePanelTitles, + useMargins = DEFAULT_DASHBOARD_OPTIONS.useMargins, + syncColors = DEFAULT_DASHBOARD_OPTIONS.syncColors, + syncCursor = DEFAULT_DASHBOARD_OPTIONS.syncCursor, + syncTooltips = DEFAULT_DASHBOARD_OPTIONS.syncTooltips, + } = JSON.parse(optionsJSON) as DashboardOptions; + return { + hidePanelTitles, + useMargins, + syncColors, + syncCursor, + syncTooltips, + }; +} + +function panelsOut(panelsJSON: string): DashboardAttributes['panels'] { + const panels = JSON.parse(panelsJSON) as SavedDashboardPanel[]; + return panels.map( + ({ embeddableConfig, gridData, id, panelIndex, panelRefName, title, type, version }) => ({ + gridData, + id, + panelConfig: embeddableConfig, + panelIndex, + panelRefName, + title, + type, + version, + }) + ); +} + +export function dashboardAttributesOut( + attributes: DashboardSavedObjectAttributes | Partial +): DashboardAttributes | Partial { + const { + controlGroupInput, + description, + kibanaSavedObjectMeta, + optionsJSON, + panelsJSON, + refreshInterval, + timeFrom, + timeRestore, + timeTo, + title, + version, + } = attributes; + // try to maintain a consistent (alphabetical) order of keys + return { + ...(controlGroupInput && { controlGroupInput: controlGroupInputOut(controlGroupInput) }), + ...(description && { description }), + ...(kibanaSavedObjectMeta && { + kibanaSavedObjectMeta: kibanaSavedObjectMetaOut(kibanaSavedObjectMeta), + }), + ...(optionsJSON && { options: optionsOut(optionsJSON) }), + ...(panelsJSON && { panels: panelsOut(panelsJSON) }), + ...(refreshInterval && { + refreshInterval: { pause: refreshInterval.pause, value: refreshInterval.value }, + }), + ...(timeFrom && { timeFrom }), + timeRestore: timeRestore ?? false, + ...(timeTo && { timeTo }), + title, + ...(version && { version }), + }; +} + +function controlGroupInputIn( + controlGroupInput?: ControlGroupAttributes +): DashboardSavedObjectAttributes['controlGroupInput'] | undefined { + if (!controlGroupInput) { + return; + } + const { controls, ignoreParentSettings, labelPosition, chainingSystem, autoApplySelections } = + controlGroupInput; + const updatedControls = Object.fromEntries( + controls.map(({ controlConfig, id = uuidv4(), ...restOfControl }) => { + return [id, { ...restOfControl, explicitInput: { ...controlConfig, id } }]; + }) + ); + return { + chainingSystem, + controlStyle: labelPosition, + ignoreParentSettingsJSON: JSON.stringify(ignoreParentSettings), + panelsJSON: JSON.stringify(updatedControls), + showApplySelections: !autoApplySelections, + }; +} + +function panelsIn( + panels: DashboardAttributes['panels'] +): DashboardSavedObjectAttributes['panelsJSON'] { + const updatedPanels = panels.map(({ panelIndex, gridData, panelConfig, ...restPanel }) => { + const idx = panelIndex ?? uuidv4(); + return { + ...restPanel, + embeddableConfig: panelConfig, + panelIndex: idx, + gridData: { + ...gridData, + i: idx, + }, + }; + }); + + return JSON.stringify(updatedPanels); +} + +function kibanaSavedObjectMetaIn( + kibanaSavedObjectMeta: DashboardAttributes['kibanaSavedObjectMeta'] +) { + const { searchSource } = kibanaSavedObjectMeta; + return { searchSourceJSON: JSON.stringify(searchSource ?? {}) }; +} + +export const getResultV3ToV2 = (result: DashboardGetOut): DashboardCrudTypesV2['GetOut'] => { + const { meta, item } = result; + const { attributes, ...rest } = item; + const { + controlGroupInput, + description, + kibanaSavedObjectMeta, + options, + panels, + refreshInterval, + timeFrom, + timeRestore, + timeTo, + title, + version, + } = attributes; + + const v2Attributes = { + ...(controlGroupInput && { + controlGroupInput: controlGroupInputIn(controlGroupInput) as ControlGroupAttributesV2, + }), + description, + ...(kibanaSavedObjectMeta && { + kibanaSavedObjectMeta: kibanaSavedObjectMetaIn(kibanaSavedObjectMeta), + }), + ...(options && { optionsJSON: JSON.stringify(options) }), + panelsJSON: panels ? panelsIn(panels) : '[]', + refreshInterval, + ...(timeFrom && { timeFrom }), + timeRestore, + ...(timeTo && { timeTo }), + title, + ...(version && { version }), + }; + return { + meta, + item: { + ...rest, + attributes: v2Attributes, + }, + }; +}; + +export const itemAttrsToSavedObjectAttrs = ( + attributes: DashboardAttributes +): ItemAttrsToSavedObjectAttrsReturn => { + try { + const { controlGroupInput, kibanaSavedObjectMeta, options, panels, ...rest } = attributes; + const soAttributes = { + ...rest, + ...(controlGroupInput && { + controlGroupInput: controlGroupInputIn(controlGroupInput), + }), + ...(options && { + optionsJSON: JSON.stringify(options), + }), + ...(panels && { + panelsJSON: panelsIn(panels), + }), + ...(kibanaSavedObjectMeta && { + kibanaSavedObjectMeta: kibanaSavedObjectMetaIn(kibanaSavedObjectMeta), + }), + }; + return { attributes: soAttributes, error: null }; + } catch (e) { + return { attributes: null, error: e }; + } +}; + +type PartialSavedObject = Omit>, 'references'> & { + references: SavedObjectReference[] | undefined; +}; + +export function savedObjectToItem( + savedObject: SavedObject, + partial: false, + allowedAttributes?: string[] +): SavedObjectToItemReturn; + +export function savedObjectToItem( + savedObject: PartialSavedObject, + partial: true, + allowedAttributes?: string[] +): SavedObjectToItemReturn; + +export function savedObjectToItem( + savedObject: + | SavedObject + | PartialSavedObject, + partial: boolean, + allowedAttributes?: string[] +): SavedObjectToItemReturn { + const { + id, + type, + updated_at: updatedAt, + updated_by: updatedBy, + created_at: createdAt, + created_by: createdBy, + attributes, + error, + namespaces, + references, + version, + managed, + } = savedObject; + + try { + const attributesOut = allowedAttributes + ? pick(dashboardAttributesOut(attributes), allowedAttributes) + : dashboardAttributesOut(attributes); + return { + item: { + id, + type, + updatedAt, + updatedBy, + createdAt, + createdBy, + attributes: attributesOut, + error, + namespaces, + references, + version, + managed, + }, + error: null, + }; + } catch (e) { + return { item: null, error: e }; + } +} diff --git a/src/plugins/dashboard/server/content_management/v3/types.ts b/src/plugins/dashboard/server/content_management/v3/types.ts new file mode 100644 index 000000000000..36f277ff3b26 --- /dev/null +++ b/src/plugins/dashboard/server/content_management/v3/types.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { TypeOf } from '@kbn/config-schema'; +import { + CreateIn, + GetIn, + SearchIn, + SearchResult, + UpdateIn, +} from '@kbn/content-management-plugin/common'; +import { SavedObjectReference } from '@kbn/core-saved-objects-api-server'; +import { + dashboardItemSchema, + controlGroupInputSchema, + gridDataSchema, + panelSchema, + dashboardAttributesSchema, + dashboardCreateOptionsSchema, + dashboardCreateResultSchema, + dashboardGetResultSchema, + dashboardSearchOptionsSchema, + dashboardSearchResultsSchema, + dashboardUpdateOptionsSchema, + optionsSchema, +} from './cm_services'; +import { CONTENT_ID } from '../../../common/content_management'; +import { DashboardSavedObjectAttributes } from '../../dashboard_saved_object'; + +export type DashboardOptions = TypeOf; + +// Panel config has some defined types but also allows for custom keys added by embeddables +// The schema uses "unknowns: 'allow'" to permit any other keys, but the TypeOf helper does not +// recognize this, so we need to manually extend the type here. +export type DashboardPanel = Omit, 'panelConfig'> & { + panelConfig: TypeOf['panelConfig'] & { [key: string]: any }; +}; +export type DashboardAttributes = Omit, 'panels'> & { + panels: DashboardPanel[]; +}; + +export type DashboardItem = TypeOf; +export type PartialDashboardItem = Omit & { + attributes: Partial; + references: SavedObjectReference[] | undefined; +}; + +export type ControlGroupAttributes = TypeOf; +export type GridData = TypeOf; + +export type DashboardGetIn = GetIn; +export type DashboardGetOut = TypeOf; + +export type DashboardCreateIn = CreateIn; +export type DashboardCreateOut = TypeOf; +export type DashboardCreateOptions = TypeOf; + +export type DashboardUpdateIn = UpdateIn>; +export type DashboardUpdateOut = TypeOf; +export type DashboardUpdateOptions = TypeOf; + +export type DashboardSearchIn = SearchIn; +export type DashboardSearchOptions = TypeOf; +export type DashboardSearchOut = SearchResult>; + +export type SavedObjectToItemReturn = + | { + item: T; + error: null; + } + | { + item: null; + error: Error; + }; + +export type ItemAttrsToSavedObjectAttrsReturn = + | { + attributes: DashboardSavedObjectAttributes; + error: null; + } + | { + attributes: null; + error: Error; + }; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts b/src/plugins/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts index fc551d823377..3b7f137cc1d9 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/dashboard_saved_object.ts @@ -10,19 +10,21 @@ import { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; import { SavedObjectsType } from '@kbn/core/server'; -import { dashboardAttributesSchema as dashboardAttributesSchemaV1 } from '../content_management/schema/v1'; -import { dashboardAttributesSchema as dashboardAttributesSchemaV2 } from '../content_management/schema/v2'; +import { dashboardAttributesSchema as dashboardAttributesSchemaV1 } from './schema/v1'; +import { dashboardAttributesSchema as dashboardAttributesSchemaV2 } from './schema/v2'; import { createDashboardSavedObjectTypeMigrations, DashboardSavedObjectTypeMigrationsDeps, } from './migrations/dashboard_saved_object_migrations'; +export const DASHBOARD_SAVED_OBJECT_TYPE = 'dashboard'; + export const createDashboardSavedObjectType = ({ migrationDeps, }: { migrationDeps: DashboardSavedObjectTypeMigrationsDeps; }): SavedObjectsType => ({ - name: 'dashboard', + name: DASHBOARD_SAVED_OBJECT_TYPE, indexPattern: ANALYTICS_SAVED_OBJECT_INDEX, hidden: false, namespaceType: 'multiple-isolated', diff --git a/src/plugins/dashboard/server/dashboard_saved_object/index.ts b/src/plugins/dashboard/server/dashboard_saved_object/index.ts index 912c508f0ced..23c91f2c6ea3 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/index.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/index.ts @@ -7,4 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { createDashboardSavedObjectType } from './dashboard_saved_object'; +export { + createDashboardSavedObjectType, + DASHBOARD_SAVED_OBJECT_TYPE, +} from './dashboard_saved_object'; +export type { DashboardSavedObjectAttributes, GridData, SavedDashboardPanel } from './schema'; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.test.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.test.ts index 7abb1523e361..b5f5b6b20b31 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.test.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/dashboard_saved_object_migrations.test.ts @@ -613,12 +613,11 @@ describe('dashboard', () => { expect(newDoc).toMatchInlineSnapshot(` Object { "attributes": Object { - "description": "", "kibanaSavedObjectMeta": Object { "searchSourceJSON": "{\\"query\\":{\\"language\\":\\"kuery\\",\\"query\\":\\"\\"},\\"filter\\":[{\\"query\\":{\\"match_phrase\\":{\\"machine.os.keyword\\":\\"osx\\"}},\\"$state\\":{\\"store\\":\\"appState\\"},\\"meta\\":{\\"type\\":\\"phrase\\",\\"key\\":\\"machine.os.keyword\\",\\"params\\":{\\"query\\":\\"osx\\"},\\"disabled\\":false,\\"negate\\":false,\\"alias\\":null,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\\"}}]}", }, - "optionsJSON": "{\\"useMargins\\":true,\\"hidePanelTitles\\":false}", - "panelsJSON": "[{\\"version\\":\\"7.9.3\\",\\"type\\":\\"visualization\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\"},\\"panelIndex\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\",\\"embeddableConfig\\":{\\"enhancements\\":{\\"dynamicActions\\":{\\"events\\":[]}}},\\"panelRefName\\":\\"panel_82fa0882-9f9e-476a-bbb9-03555e5ced91\\"}]", + "optionsJSON": "{\\"hidePanelTitles\\":false,\\"useMargins\\":true,\\"syncColors\\":true,\\"syncCursor\\":true,\\"syncTooltips\\":true}", + "panelsJSON": "[{\\"version\\":\\"7.9.3\\",\\"type\\":\\"visualization\\",\\"panelRefName\\":\\"panel_82fa0882-9f9e-476a-bbb9-03555e5ced91\\",\\"embeddableConfig\\":{\\"enhancements\\":{\\"dynamicActions\\":{\\"events\\":[]}}},\\"panelIndex\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\"}}]", "timeRestore": false, "title": "Dashboard A", "version": 1, @@ -710,7 +709,7 @@ describe('dashboard', () => { contextMock ); expect(migratedDoc.attributes.panelsJSON).toMatchInlineSnapshot( - `"[{\\"version\\":\\"7.9.3\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"0\\"},\\"panelIndex\\":\\"0\\",\\"embeddableConfig\\":{}},{\\"version\\":\\"7.13.0\\",\\"gridData\\":{\\"x\\":24,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"1\\"},\\"panelIndex\\":\\"1\\",\\"embeddableConfig\\":{\\"attributes\\":{\\"byValueThing\\":\\"ThisIsByValue\\"},\\"superCoolKey\\":\\"ONLY 4 BY VALUE EMBEDDABLES THANK YOU VERY MUCH\\"}}]"` + `"[{\\"version\\":\\"7.9.3\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"0\\"},\\"panelIndex\\":\\"0\\",\\"embeddableConfig\\":{}},{\\"gridData\\":{\\"x\\":24,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"1\\"},\\"panelIndex\\":\\"1\\",\\"embeddableConfig\\":{\\"attributes\\":{\\"byValueThing\\":\\"ThisIsByValue\\"},\\"superCoolKey\\":\\"ONLY 4 BY VALUE EMBEDDABLES THANK YOU VERY MUCH\\"},\\"version\\":\\"7.13.0\\"}]"` ); }); }); diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts index 1b1d04cdebf7..0e32e2feec30 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_by_value_dashboard_panels.ts @@ -9,8 +9,8 @@ import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common'; import { - controlGroupSerializedStateToSerializableRuntimeState, - serializableRuntimeStateToControlGroupSerializedState, + controlGroupSavedObjectStateToSerializableRuntimeState, + serializableRuntimeStateToControlGroupSavedObjectState, } from '@kbn/controls-plugin/server'; import { Serializable, SerializableRecord } from '@kbn/utility-types'; import { SavedObjectMigrationFn } from '@kbn/core/server'; @@ -20,8 +20,8 @@ import { SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common'; import { convertPanelStateToSavedDashboardPanel, convertSavedDashboardPanelToPanelState, -} from '../../../common'; -import { SavedDashboardPanel } from '../../../common/content_management'; +} from './utils'; +import type { SavedDashboardPanel } from '..'; type ValueOrReferenceInput = SavedObjectEmbeddableInput & { attributes?: Serializable; @@ -35,7 +35,7 @@ export const migrateByValueDashboardPanels = const { attributes } = doc; if (attributes?.controlGroupInput) { - const controlGroupState = controlGroupSerializedStateToSerializableRuntimeState( + const controlGroupState = controlGroupSavedObjectStateToSerializableRuntimeState( attributes.controlGroupInput ); const migratedControlGroupInput = migrate({ @@ -43,7 +43,7 @@ export const migrateByValueDashboardPanels = type: CONTROL_GROUP_TYPE, } as SerializableRecord); attributes.controlGroupInput = - serializableRuntimeStateToControlGroupSerializedState(migratedControlGroupInput); + serializableRuntimeStateToControlGroupSavedObjectState(migratedControlGroupInput); } // Skip if panelsJSON is missing otherwise this will cause saved object import to fail when diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_extract_panel_references.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_extract_panel_references.ts index 1782a63beda5..091ef2132267 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_extract_panel_references.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_extract_panel_references.ts @@ -7,11 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SavedObjectMigrationFn } from '@kbn/core/server'; +import { SavedObject, SavedObjectMigrationFn } from '@kbn/core/server'; import { extractReferences, injectReferences } from '../../../common'; -import { DashboardAttributes } from '../../../common/content_management'; -import { DashboardSavedObjectTypeMigrationsDeps } from './dashboard_saved_object_migrations'; +import type { DashboardSavedObjectTypeMigrationsDeps } from './dashboard_saved_object_migrations'; +import type { DashboardSavedObjectAttributes } from '../schema'; +import { itemAttrsToSavedObjectAttrs, savedObjectToItem } from '../../content_management/latest'; /** * In 7.8.0 we introduced dashboard drilldowns which are stored inside dashboard saved object as part of embeddable state @@ -26,7 +27,7 @@ import { DashboardSavedObjectTypeMigrationsDeps } from './dashboard_saved_object */ export function createExtractPanelReferencesMigration( deps: DashboardSavedObjectTypeMigrationsDeps -): SavedObjectMigrationFn { +): SavedObjectMigrationFn { return (doc) => { const references = doc.references ?? []; @@ -36,19 +37,32 @@ export function createExtractPanelReferencesMigration( */ const oldNonPanelReferences = references.filter((ref) => !ref.name.startsWith('panel_')); + // Use Content Management to convert the saved object to the DashboardAttributes + // expected by injectReferences + const { item, error: itemError } = savedObjectToItem( + doc as unknown as SavedObject, + false + ); + + if (itemError) throw itemError; + + const parsedAttributes = item.attributes; const injectedAttributes = injectReferences( { - attributes: doc.attributes, + attributes: parsedAttributes, references, }, { embeddablePersistableStateService: deps.embeddable } ); - const { attributes, references: newPanelReferences } = extractReferences( + const { attributes: extractedAttributes, references: newPanelReferences } = extractReferences( { attributes: injectedAttributes, references: [] }, { embeddablePersistableStateService: deps.embeddable } ); + const { attributes, error: attributesError } = itemAttrsToSavedObjectAttrs(extractedAttributes); + if (attributesError) throw attributesError; + return { ...doc, references: [...oldNonPanelReferences, ...newPanelReferences], diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts index c223ff0bc32a..77c114315ba1 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_hidden_titles.ts @@ -7,14 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SavedObjectMigrationFn } from '@kbn/core/server'; -import { EmbeddableInput } from '@kbn/embeddable-plugin/common'; +import type { SavedObjectMigrationFn } from '@kbn/core/server'; +import type { EmbeddableInput } from '@kbn/embeddable-plugin/common'; +import type { SavedDashboardPanel } from '../schema'; import { convertSavedDashboardPanelToPanelState, convertPanelStateToSavedDashboardPanel, -} from '../../../common'; -import { SavedDashboardPanel } from '../../../common/content_management'; +} from './utils'; /** * Before 7.10, hidden panel titles were stored as a blank string on the title attribute. In 7.10, this was replaced diff --git a/src/plugins/dashboard/server/dashboard_saved_object/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 index ab05f64a2d71..e23ccfd00153 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/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 @@ -13,7 +13,7 @@ import semverSatisfies from 'semver/functions/satisfies'; import { i18n } from '@kbn/i18n'; import type { SerializableRecord } from '@kbn/utility-types'; -import { +import type { SavedDashboardPanel620, SavedDashboardPanel630, SavedDashboardPanel610, @@ -25,7 +25,7 @@ import { RawSavedDashboardPanel640To720, RawSavedDashboardPanel730ToLatest, } from './types'; -import { GridData } from '../../../../common/content_management'; +import type { GridData } from '../../../content_management'; const PANEL_HEIGHT_SCALE_FACTOR = 5; const PANEL_HEIGHT_SCALE_FACTOR_WITH_MARGINS = 4; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.ts index 6af69882b077..6da3c1510530 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/migrations_730.ts @@ -49,7 +49,7 @@ export const migrations730 = (doc: DashboardDoc700To720, { log }: SavedObjectMig } try { - const searchSource = JSON.parse(doc.attributes.kibanaSavedObjectMeta.searchSourceJSON); + const searchSource = JSON.parse(doc.attributes.kibanaSavedObjectMeta.searchSourceJSON!); doc.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify( moveFiltersToQuery(searchSource) ); diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/types.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/types.ts index 750fd736c966..585b9c55d501 100644 --- a/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/types.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/migrate_to_730/types.ts @@ -10,14 +10,12 @@ import type { Serializable } from '@kbn/utility-types'; import { SavedObjectReference } from '@kbn/core/server'; -import type { - GridData, - DashboardAttributes as CurrentDashboardAttributes, // Dashboard attributes from common are the source of truth for the current version. -} from '../../../../common/content_management'; +import type { GridData } from '../../../content_management'; +import type { DashboardSavedObjectAttributes } from '../../schema'; interface KibanaAttributes { kibanaSavedObjectMeta: { - searchSourceJSON: string; + searchSourceJSON?: string; }; } @@ -45,7 +43,7 @@ interface DashboardAttributesTo720 extends KibanaAttributes { optionsJSON?: string; } -export type DashboardDoc730ToLatest = Doc; +export type DashboardDoc730ToLatest = Doc; export type DashboardDoc700To720 = Doc; diff --git a/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.test.ts similarity index 82% rename from src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts rename to src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.test.ts index f750d95ca2ef..17aca8fef68c 100644 --- a/src/plugins/dashboard/common/lib/dashboard_panel_converters.test.ts +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.test.ts @@ -7,13 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { EmbeddableInput } from '@kbn/embeddable-plugin/common/types'; +import type { SavedDashboardPanel } from '../schema'; +import type { DashboardPanelState } from '../../../common'; + import { convertSavedDashboardPanelToPanelState, convertPanelStateToSavedDashboardPanel, -} from './dashboard_panel_converters'; -import { SavedDashboardPanel } from '../content_management'; -import { DashboardPanelState } from '../dashboard_container/types'; -import { EmbeddableInput } from '@kbn/embeddable-plugin/common/types'; +} from './utils'; test('convertSavedDashboardPanelToPanelState', () => { const savedDashboardPanel: SavedDashboardPanel = { @@ -148,7 +149,7 @@ test('convertPanelStateToSavedDashboardPanel will not leave title as part of emb expect(converted.title).toBe('title'); }); -test('convertPanelStateToSavedDashboardPanel retains legacy version info when not passed removeLegacyVersion', () => { +test('convertPanelStateToSavedDashboardPanel retains legacy version info', () => { const dashboardPanel: DashboardPanelState = { gridData: { x: 0, @@ -168,24 +169,3 @@ test('convertPanelStateToSavedDashboardPanel retains legacy version info when no const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel); expect(converted.version).toBe('8.10.0'); }); - -test('convertPanelStateToSavedDashboardPanel removes legacy version info when passed removeLegacyVersion', () => { - const dashboardPanel: DashboardPanelState = { - gridData: { - x: 0, - y: 0, - h: 15, - w: 15, - i: '123', - }, - explicitInput: { - id: '123', - title: 'title', - } as EmbeddableInput, - type: 'search', - version: '8.10.0', - }; - - const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel, true); - expect(converted.version).not.toBeDefined(); -}); diff --git a/src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.ts b/src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.ts new file mode 100644 index 000000000000..4ed8ec5b8e97 --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/migrations/utils.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { omit } from 'lodash'; +import type { EmbeddableInput, SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common'; +import type { SavedDashboardPanel } from '../schema'; +import type { DashboardPanelState } from '../../../common'; + +export function convertSavedDashboardPanelToPanelState< + TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput +>(savedDashboardPanel: SavedDashboardPanel): DashboardPanelState { + return { + type: savedDashboardPanel.type, + gridData: savedDashboardPanel.gridData, + panelRefName: savedDashboardPanel.panelRefName, + explicitInput: { + id: savedDashboardPanel.panelIndex, + ...(savedDashboardPanel.id !== undefined && { savedObjectId: savedDashboardPanel.id }), + ...(savedDashboardPanel.title !== undefined && { title: savedDashboardPanel.title }), + ...savedDashboardPanel.embeddableConfig, + } as TEmbeddableInput, + version: savedDashboardPanel.version, + }; +} + +export function convertPanelStateToSavedDashboardPanel( + panelState: DashboardPanelState +): SavedDashboardPanel { + const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId; + const panelIndex = panelState.explicitInput.id; + return { + type: panelState.type, + gridData: { + ...panelState.gridData, + i: panelIndex, + }, + panelIndex, + embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']), + ...(panelState.explicitInput.title !== undefined && { title: panelState.explicitInput.title }), + ...(savedObjectId !== undefined && { id: savedObjectId }), + ...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }), + ...(panelState.version !== undefined && { version: panelState.version }), + }; +} diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/index.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/index.ts new file mode 100644 index 000000000000..4c50de472f53 --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { DashboardSavedObjectAttributes, GridData, SavedDashboardPanel } from './latest'; +export { dashboardSavedObjectSchema } from './latest'; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/latest.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/latest.ts new file mode 100644 index 000000000000..a40e476abe79 --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/latest.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +// Latest model version for dashboard saved objects is v2 +export { + dashboardAttributesSchema as dashboardSavedObjectSchema, + type DashboardAttributes as DashboardSavedObjectAttributes, + type GridData, + type SavedDashboardPanel, +} from './v2'; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/index.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/index.ts new file mode 100644 index 000000000000..e52a6ca4075a --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { DashboardAttributes } from './types'; +export { controlGroupInputSchema, dashboardAttributesSchema } from './v1'; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/types.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/types.ts new file mode 100644 index 000000000000..8717851845cf --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { TypeOf } from '@kbn/config-schema'; +import { dashboardAttributesSchema } from './v1'; + +export type DashboardAttributes = TypeOf; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/v1.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/v1.ts new file mode 100644 index 000000000000..63b4cd3c2c10 --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v1/v1.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { schema } from '@kbn/config-schema'; + +export const controlGroupInputSchema = schema + .object({ + panelsJSON: schema.maybe(schema.string()), + controlStyle: schema.maybe(schema.string()), + chainingSystem: schema.maybe(schema.string()), + ignoreParentSettingsJSON: schema.maybe(schema.string()), + }) + .extends({}, { unknowns: 'ignore' }); + +export const dashboardAttributesSchema = schema.object( + { + // General + title: schema.string(), + description: schema.string({ defaultValue: '' }), + + // Search + kibanaSavedObjectMeta: schema.object({ + searchSourceJSON: schema.maybe(schema.string()), + }), + + // Time + timeRestore: schema.maybe(schema.boolean()), + timeFrom: schema.maybe(schema.string()), + timeTo: schema.maybe(schema.string()), + refreshInterval: schema.maybe( + schema.object({ + pause: schema.boolean(), + value: schema.number(), + display: schema.maybe(schema.string()), + section: schema.maybe(schema.number()), + }) + ), + + // Dashboard Content + controlGroupInput: schema.maybe(controlGroupInputSchema), + panelsJSON: schema.string({ defaultValue: '[]' }), + optionsJSON: schema.maybe(schema.string()), + + // Legacy + hits: schema.maybe(schema.number()), + version: schema.maybe(schema.number()), + }, + { unknowns: 'forbid' } +); diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/index.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/index.ts new file mode 100644 index 000000000000..2fda02230ed6 --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { DashboardAttributes, GridData, SavedDashboardPanel } from './types'; +export { controlGroupInputSchema, dashboardAttributesSchema } from './v2'; diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/types.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/types.ts new file mode 100644 index 000000000000..e50a27efe2b3 --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/types.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { Serializable } from '@kbn/utility-types'; +import { TypeOf } from '@kbn/config-schema'; +import { dashboardAttributesSchema, gridDataSchema } from './v2'; + +export type DashboardAttributes = TypeOf; +export type GridData = TypeOf; + +/** + * 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; + title?: string; + + /** + * This version key was used to store Kibana version information from versions 7.3.0 -> 8.11.0. + * As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the + * embeddable's input. (embeddableConfig in this type). + */ + version?: string; +} diff --git a/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/v2.ts b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/v2.ts new file mode 100644 index 000000000000..dc0ed3eb84cb --- /dev/null +++ b/src/plugins/dashboard/server/dashboard_saved_object/schema/v2/v2.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { schema } from '@kbn/config-schema'; +import { + controlGroupInputSchema as controlGroupInputSchemaV1, + dashboardAttributesSchema as dashboardAttributesSchemaV1, +} from '../v1'; + +export const controlGroupInputSchema = controlGroupInputSchemaV1.extends( + { + showApplySelections: schema.maybe(schema.boolean()), + }, + { unknowns: 'ignore' } +); + +export const dashboardAttributesSchema = dashboardAttributesSchemaV1.extends( + { + controlGroupInput: schema.maybe(controlGroupInputSchema), + }, + { unknowns: 'ignore' } +); + +export const gridDataSchema = schema.object({ + x: schema.number(), + y: schema.number(), + w: schema.number(), + h: schema.number(), + i: schema.string(), +}); diff --git a/src/plugins/dashboard/server/index.ts b/src/plugins/dashboard/server/index.ts index f76a75cb837b..94e7ed14378c 100644 --- a/src/plugins/dashboard/server/index.ts +++ b/src/plugins/dashboard/server/index.ts @@ -26,3 +26,7 @@ export async function plugin(initializerContext: PluginInitializerContext) { } export type { DashboardPluginSetup, DashboardPluginStart } from './types'; +export type { DashboardAttributes } from './content_management'; +export type { DashboardSavedObjectAttributes } from './dashboard_saved_object'; + +export { PUBLIC_API_PATH } from './api/constants'; diff --git a/src/plugins/dashboard/server/plugin.ts b/src/plugins/dashboard/server/plugin.ts index 3218ee85ef38..e3d67ca10716 100644 --- a/src/plugins/dashboard/server/plugin.ts +++ b/src/plugins/dashboard/server/plugin.ts @@ -30,6 +30,7 @@ import { createDashboardSavedObjectType } from './dashboard_saved_object'; import { CONTENT_ID, LATEST_VERSION } from '../common/content_management'; import { registerDashboardUsageCollector } from './usage/register_collector'; import { dashboardPersistableStateServiceFactory } from './dashboard_container/dashboard_container_embeddable_factory'; +import { registerAPIRoutes } from './api'; interface SetupDeps { embeddable: EmbeddableSetup; @@ -111,6 +112,12 @@ export class DashboardPlugin core.uiSettings.register(getUISettings()); + registerAPIRoutes({ + http: core.http, + contentManagement: plugins.contentManagement, + logger: this.logger, + }); + return {}; } diff --git a/src/plugins/dashboard/server/usage/dashboard_telemetry.test.ts b/src/plugins/dashboard/server/usage/dashboard_telemetry.test.ts index 225ac7743d23..8f4f94d3621e 100644 --- a/src/plugins/dashboard/server/usage/dashboard_telemetry.test.ts +++ b/src/plugins/dashboard/server/usage/dashboard_telemetry.test.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SavedDashboardPanel } from '../../common/content_management'; +import { SavedDashboardPanel } from '../dashboard_saved_object'; import { getEmptyDashboardData, collectPanelsByType } from './dashboard_telemetry'; import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; import { createEmbeddablePersistableStateServiceMock } from '@kbn/embeddable-plugin/common/mocks'; diff --git a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts index 0048e081e6f6..f26de753c12e 100644 --- a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts +++ b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts @@ -17,7 +17,7 @@ import { import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; -import { DashboardAttributes, SavedDashboardPanel } from '../../common/content_management'; +import { DashboardSavedObjectAttributes, SavedDashboardPanel } from '../dashboard_saved_object'; import { TASK_ID } from './dashboard_telemetry_collection_task'; import { emptyState, type LatestTaskStateSchema } from './task_state'; @@ -95,7 +95,7 @@ export const collectPanelsByType = ( export const controlsCollectorFactory = (embeddableService: EmbeddablePersistableStateService) => - (attributes: DashboardAttributes, collectorData: DashboardCollectorData) => { + (attributes: DashboardSavedObjectAttributes, collectorData: DashboardCollectorData) => { if (!isEmpty(attributes.controlGroupInput)) { collectorData.controls = embeddableService.telemetry( { diff --git a/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts b/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts index d660af962db5..7eb4cebc39e4 100644 --- a/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts +++ b/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts @@ -23,9 +23,15 @@ import { collectPanelsByType, getEmptyDashboardData, } from './dashboard_telemetry'; -import { injectReferences } from '../../common'; -import { DashboardAttributesAndReferences } from '../../common/types'; -import { DashboardAttributes, SavedDashboardPanel } from '../../common/content_management'; +import type { + DashboardSavedObjectAttributes, + SavedDashboardPanel, +} from '../dashboard_saved_object'; + +interface DashboardSavedObjectAttributesAndReferences { + attributes: DashboardSavedObjectAttributes; + references: SavedObjectReference[]; +} // This task is responsible for running daily and aggregating all the Dashboard telemerty data // into a single document. This is an effort to make sure the load of fetching/parsing all of the @@ -88,17 +94,18 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable: async run() { let dashboardData = getEmptyDashboardData(); const controlsCollector = controlsCollectorFactory(embeddable); - const processDashboards = (dashboards: DashboardAttributesAndReferences[]) => { + const processDashboards = (dashboards: DashboardSavedObjectAttributesAndReferences[]) => { for (const dashboard of dashboards) { - const attributes = injectReferences(dashboard, { - embeddablePersistableStateService: embeddable, - }); + // TODO is this injecting references really necessary? + // const attributes = injectReferences(dashboard, { + // embeddablePersistableStateService: embeddable, + // }); - dashboardData = controlsCollector(attributes, dashboardData); + dashboardData = controlsCollector(dashboard.attributes, dashboardData); try { const panels = JSON.parse( - attributes.panelsJSON as string + dashboard.attributes.panelsJSON as string ) as unknown as SavedDashboardPanel[]; collectPanelsByType(panels, dashboardData, embeddable); @@ -129,7 +136,7 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable: const esClient = await getEsClient(); let result = await esClient.search<{ - dashboard: DashboardAttributes; + dashboard: DashboardSavedObjectAttributes; references: SavedObjectReference[]; }>(searchParams); @@ -144,8 +151,8 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable: } return undefined; }) - .filter( - (s): s is DashboardAttributesAndReferences => s !== undefined + .filter( + (s): s is DashboardSavedObjectAttributesAndReferences => s !== undefined ) ); @@ -163,8 +170,8 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable: } return undefined; }) - .filter( - (s): s is DashboardAttributesAndReferences => s !== undefined + .filter( + (s): s is DashboardSavedObjectAttributesAndReferences => s !== undefined ) ); } diff --git a/src/plugins/links/public/types.ts b/src/plugins/links/public/types.ts index 97b1f0254f4e..df3eb7fc2b51 100644 --- a/src/plugins/links/public/types.ts +++ b/src/plugins/links/public/types.ts @@ -22,7 +22,7 @@ import { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/p import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers'; import { LocatorPublic } from '@kbn/share-plugin/common'; import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '@kbn/dashboard-plugin/public'; -import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import type { DashboardAttributes } from '@kbn/dashboard-plugin/server'; import { CONTENT_ID } from '../common'; import { Link, LinksAttributes, LinksLayoutType } from '../common/content_management'; @@ -73,5 +73,5 @@ export type ResolvedLink = Link & { export interface DashboardItem { id: string; - attributes: DashboardAttributes; + attributes: Pick; } diff --git a/test/api_integration/apis/dashboards/create_dashboard/index.ts b/test/api_integration/apis/dashboards/create_dashboard/index.ts new file mode 100644 index 000000000000..c9c2f63dd3b8 --- /dev/null +++ b/test/api_integration/apis/dashboards/create_dashboard/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + describe('dashboards - create', () => { + before(async () => { + await kibanaServer.importExport.load( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.unload( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + loadTestFile(require.resolve('./main')); + loadTestFile(require.resolve('./validation')); + }); +} diff --git a/test/api_integration/apis/dashboards/create_dashboard/main.ts b/test/api_integration/apis/dashboards/create_dashboard/main.ts new file mode 100644 index 000000000000..3b8b71f827de --- /dev/null +++ b/test/api_integration/apis/dashboards/create_dashboard/main.ts @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { DEFAULT_IGNORE_PARENT_SETTINGS } from '@kbn/controls-plugin/common'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('main', () => { + it('sets top level default values', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + }, + }); + + expect(response.status).to.be(200); + expect(response.body.item.attributes.kibanaSavedObjectMeta.searchSource).to.eql({}); + expect(response.body.item.attributes.panels).to.eql([]); + expect(response.body.item.attributes.timeRestore).to.be(false); + expect(response.body.item.attributes.options).to.eql({ + hidePanelTitles: false, + useMargins: true, + syncColors: true, + syncTooltips: true, + syncCursor: true, + }); + }); + + it('sets panels default values', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + panels: [ + { + type: 'visualization', + gridData: { + x: 0, + y: 0, + w: 24, + h: 15, + }, + panelConfig: {}, + }, + ], + }, + }); + + expect(response.status).to.be(200); + expect(response.body.item.attributes.panels).to.be.an('array'); + // panel index is a random uuid when not provided + expect(response.body.item.attributes.panels[0].panelIndex).match(/^[0-9a-f-]{36}$/); + expect(response.body.item.attributes.panels[0].panelIndex).to.eql( + response.body.item.attributes.panels[0].gridData.i + ); + }); + + it('sets controls default values', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + controlGroupInput: { + controls: [ + { + type: 'optionsListControl', + order: 0, + width: 'medium', + grow: true, + controlConfig: { + title: 'Origin City', + fieldName: 'OriginCityName', + dataViewId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + selectedOptions: [], + enhancements: {}, + }, + }, + ], + }, + }, + }); + + expect(response.status).to.be(200); + // generates a random saved object id + expect(response.body.item.id).match(/^[0-9a-f-]{36}$/); + // saved object stores controls panels as an object, but the API should return as an array + expect(response.body.item.attributes.controlGroupInput.controls).to.be.an('array'); + + expect(response.body.item.attributes.controlGroupInput.ignoreParentSettings).to.eql( + DEFAULT_IGNORE_PARENT_SETTINGS + ); + }); + + it('can create a dashboard with a specific id', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + const id = `bar-${Date.now()}-${Math.random()}`; + + const response = await supertest + .post(`${PUBLIC_API_PATH}/${id}`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { title }, + }); + + expect(response.status).to.be(200); + expect(response.body.item.id).to.be(id); + }); + + it('creates a dashboard with references', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + panels: [ + { + type: 'visualization', + gridData: { + x: 0, + y: 0, + w: 24, + h: 15, + i: 'bizz', + }, + panelConfig: {}, + panelIndex: 'bizz', + panelRefName: 'panel_bizz', + }, + ], + }, + references: [ + { + name: 'bizz:panel_bizz', + type: 'visualization', + id: 'my-saved-object', + }, + ], + }); + + expect(response.status).to.be(200); + expect(response.body.item.attributes.panels).to.be.an('array'); + }); + + // TODO Maybe move this test to x-pack/test/api_integration/dashboards + it('can create a dashboard in a defined space', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + + const spaceId = 'space-1'; + + const response = await supertest + .post(`/s/${spaceId}${PUBLIC_API_PATH}`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + }, + spaces: [spaceId], + }); + + expect(response.status).to.be(200); + expect(response.body.item.namespaces).to.eql([spaceId]); + }); + + it('return error if provided id already exists', async () => { + const title = `foo-${Date.now()}-${Math.random()}`; + // id is a saved object loaded by the kbn_archiver + const id = 'be3733a0-9efe-11e7-acb3-3dab96693fab'; + + const response = await supertest + .post(`${PUBLIC_API_PATH}/${id}`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title, + }, + }); + + expect(response.status).to.be(409); + expect(response.body.message).to.be( + 'A dashboard with saved object ID be3733a0-9efe-11e7-acb3-3dab96693fab already exists.' + ); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/create_dashboard/validation.ts b/test/api_integration/apis/dashboards/create_dashboard/validation.ts new file mode 100644 index 000000000000..c7f0917a7180 --- /dev/null +++ b/test/api_integration/apis/dashboards/create_dashboard/validation.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('validation', () => { + it('returns error when attributes object is not provided', async () => { + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({}); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.title]: expected value of type [string] but got [undefined]' + ); + }); + + it('returns error when title is not provided', async () => { + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: {}, + }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.title]: expected value of type [string] but got [undefined]' + ); + }); + + it('returns error if panels is not an array', async () => { + const response = await supertest + .post(PUBLIC_API_PATH) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title: 'foo', + panels: {}, + }, + }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.panels]: expected value of type [array] but got [Object]' + ); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/delete_dashboard/index.ts b/test/api_integration/apis/dashboards/delete_dashboard/index.ts new file mode 100644 index 000000000000..41494dfd986d --- /dev/null +++ b/test/api_integration/apis/dashboards/delete_dashboard/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + describe('dashboards - delete', () => { + before(async () => { + await kibanaServer.importExport.load( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.unload( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + loadTestFile(require.resolve('./main')); + }); +} diff --git a/test/api_integration/apis/dashboards/delete_dashboard/main.ts b/test/api_integration/apis/dashboards/delete_dashboard/main.ts new file mode 100644 index 000000000000..19ed2b2e1c05 --- /dev/null +++ b/test/api_integration/apis/dashboards/delete_dashboard/main.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('main', () => { + it('should return 404 for a non-existent dashboard', async () => { + const response = await supertest + .delete(`${PUBLIC_API_PATH}/non-existent-dashboard`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'A dashboard with saved object ID non-existent-dashboard was not found.', + }); + }); + + it('should return 200 if the dashboard is deleted', async () => { + const response = await supertest + .delete(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(200); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/get_dashboard/index.ts b/test/api_integration/apis/dashboards/get_dashboard/index.ts new file mode 100644 index 000000000000..82ac6f1903cb --- /dev/null +++ b/test/api_integration/apis/dashboards/get_dashboard/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + describe('dashboards - get', () => { + before(async () => { + await kibanaServer.importExport.load( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.unload( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + loadTestFile(require.resolve('./main')); + }); +} diff --git a/test/api_integration/apis/dashboards/get_dashboard/main.ts b/test/api_integration/apis/dashboards/get_dashboard/main.ts new file mode 100644 index 000000000000..b6585c0c4f48 --- /dev/null +++ b/test/api_integration/apis/dashboards/get_dashboard/main.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('main', () => { + it('should return 200 with an existing dashboard', async () => { + const response = await supertest + .get(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(200); + + expect(response.body.item.id).to.be('be3733a0-9efe-11e7-acb3-3dab96693fab'); + expect(response.body.item.type).to.be('dashboard'); + expect(response.body.item.attributes.title).to.be('Requests'); + + // Does not return unsupported options from the saved object + expect(response.body.item.attributes.options).to.not.have.keys(['darkTheme']); + expect(response.body.item.attributes.refreshInterval).to.not.have.keys(['display']); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/index.ts b/test/api_integration/apis/dashboards/index.ts new file mode 100644 index 000000000000..f844c0216892 --- /dev/null +++ b/test/api_integration/apis/dashboards/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('dashboards', () => { + loadTestFile(require.resolve('./create_dashboard')); + loadTestFile(require.resolve('./delete_dashboard')); + loadTestFile(require.resolve('./get_dashboard')); + loadTestFile(require.resolve('./update_dashboard')); + loadTestFile(require.resolve('./list_dashboards')); + }); +} diff --git a/test/api_integration/apis/dashboards/list_dashboards/index.ts b/test/api_integration/apis/dashboards/list_dashboards/index.ts new file mode 100644 index 000000000000..10f77ad3fee5 --- /dev/null +++ b/test/api_integration/apis/dashboards/list_dashboards/index.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + const supertest = getService('supertest'); + describe('dashboards - list', () => { + const createManyDashboards = async (count: number) => { + const fileChunks: string[] = []; + for (let i = 0; i < count; i++) { + const id = `test-dashboard-${i}`; + fileChunks.push( + JSON.stringify({ + type: 'dashboard', + id, + attributes: { + title: `My dashboard (${i})`, + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + }, + references: [], + }) + ); + } + + await supertest + .post(`/api/saved_objects/_import`) + .attach('file', Buffer.from(fileChunks.join('\n'), 'utf8'), 'export.ndjson') + .expect(200); + }; + before(async () => { + await createManyDashboards(100); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + loadTestFile(require.resolve('./main')); + }); +} diff --git a/test/api_integration/apis/dashboards/list_dashboards/main.ts b/test/api_integration/apis/dashboards/list_dashboards/main.ts new file mode 100644 index 000000000000..c0ef1059169e --- /dev/null +++ b/test/api_integration/apis/dashboards/list_dashboards/main.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('main', () => { + it('should retrieve a paginated list of dashboards', async () => { + const response = await supertest + .get(`${PUBLIC_API_PATH}`) + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(200); + expect(response.body.total).to.be(100); + expect(response.body.items[0].id).to.be('test-dashboard-0'); + expect(response.body.items.length).to.be(20); + }); + + it('should allow users to set a per page limit', async () => { + const response = await supertest + .get(`${PUBLIC_API_PATH}?perPage=10`) + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(200); + expect(response.body.total).to.be(100); + expect(response.body.items.length).to.be(10); + }); + + it('should allow users to paginate through the list of dashboards', async () => { + const response = await supertest + .get(`${PUBLIC_API_PATH}?page=5&perPage=10`) + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send(); + + expect(response.status).to.be(200); + expect(response.body.total).to.be(100); + expect(response.body.items.length).to.be(10); + expect(response.body.items[0].id).to.be('test-dashboard-40'); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/update_dashboard/index.ts b/test/api_integration/apis/dashboards/update_dashboard/index.ts new file mode 100644 index 000000000000..c2a8d7d16cb2 --- /dev/null +++ b/test/api_integration/apis/dashboards/update_dashboard/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + describe('dashboards - update', () => { + before(async () => { + await kibanaServer.importExport.load( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + + after(async () => { + await kibanaServer.importExport.unload( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + loadTestFile(require.resolve('./main')); + loadTestFile(require.resolve('./validation')); + }); +} diff --git a/test/api_integration/apis/dashboards/update_dashboard/main.ts b/test/api_integration/apis/dashboards/update_dashboard/main.ts new file mode 100644 index 000000000000..18a7d5ca2d3f --- /dev/null +++ b/test/api_integration/apis/dashboards/update_dashboard/main.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('main', () => { + it('should return 201 with an updated dashboard', async () => { + const response = await supertest + .put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title: 'Refresh Requests (Updated)', + options: { useMargins: false }, + panels: [ + { + type: 'visualization', + gridData: { x: 0, y: 0, w: 48, h: 60, i: '1' }, + panelIndex: '1', + panelRefName: 'panel_1', + version: '7.3.0', + }, + ], + timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', + timeRestore: true, + timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', + }, + references: [ + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + name: '1:panel_1', + type: 'visualization', + }, + ], + }); + + expect(response.status).to.be(201); + + expect(response.body.item.id).to.be('be3733a0-9efe-11e7-acb3-3dab96693fab'); + expect(response.body.item.type).to.be('dashboard'); + expect(response.body.item.attributes.title).to.be('Refresh Requests (Updated)'); + }); + + it('should return 404 when updating a non-existent dashboard', async () => { + const response = await supertest + .put(`${PUBLIC_API_PATH}/not-an-id`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title: 'Some other dashboard (updated)', + }, + }); + + expect(response.status).to.be(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'A dashboard with saved object ID not-an-id was not found.', + }); + }); + }); +} diff --git a/test/api_integration/apis/dashboards/update_dashboard/validation.ts b/test/api_integration/apis/dashboards/update_dashboard/validation.ts new file mode 100644 index 000000000000..4a7a069e2461 --- /dev/null +++ b/test/api_integration/apis/dashboards/update_dashboard/validation.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import expect from '@kbn/expect'; +import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('validation', () => { + it('returns error when attributes object is not provided', async () => { + const response = await supertest + .put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({}); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.title]: expected value of type [string] but got [undefined]' + ); + }); + + it('returns error when title is not provided', async () => { + const response = await supertest + .put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: {}, + }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.title]: expected value of type [string] but got [undefined]' + ); + }); + + it('returns error if panels is not an array', async () => { + const response = await supertest + .put(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`) + .set('kbn-xsrf', 'true') + .set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31') + .send({ + attributes: { + title: 'foo', + panels: {}, + }, + }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.attributes.panels]: expected value of type [array] but got [Object]' + ); + }); + }); +} diff --git a/test/api_integration/apis/index.ts b/test/api_integration/apis/index.ts index bbd7c3abf864..af1cbf2464fa 100644 --- a/test/api_integration/apis/index.ts +++ b/test/api_integration/apis/index.ts @@ -14,6 +14,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./console')); loadTestFile(require.resolve('./core')); loadTestFile(require.resolve('./custom_integration')); + loadTestFile(require.resolve('./dashboards')); loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./home')); loadTestFile(require.resolve('./data_view_field_editor')); diff --git a/test/functional/apps/dashboard/group3/dashboard_state.ts b/test/functional/apps/dashboard/group3/dashboard_state.ts index 40022c155f45..9822c2ce361a 100644 --- a/test/functional/apps/dashboard/group3/dashboard_state.ts +++ b/test/functional/apps/dashboard/group3/dashboard_state.ts @@ -309,12 +309,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { panels: (appState.panels ?? []).map((panel) => { return { ...panel, - embeddableConfig: { - ...(panel.embeddableConfig ?? {}), + panelConfig: { + ...(panel.panelConfig ?? {}), vis: { - ...((panel.embeddableConfig?.vis as object) ?? {}), + ...((panel.panelConfig?.vis as object) ?? {}), colors: { - ...((panel.embeddableConfig?.vis as { colors: object })?.colors ?? {}), + ...((panel.panelConfig?.vis as { colors: object })?.colors ?? {}), ['80000']: 'FFFFFF', }, }, @@ -353,10 +353,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { panels: (appState.panels ?? []).map((panel) => { return { ...panel, - embeddableConfig: { - ...(panel.embeddableConfig ?? {}), + panelConfig: { + ...(panel.panelConfig ?? {}), vis: { - ...((panel.embeddableConfig?.vis as object) ?? {}), + ...((panel.panelConfig?.vis as object) ?? {}), colors: {}, }, }, diff --git a/x-pack/plugins/lens/common/embeddable_factory/index.ts b/x-pack/plugins/lens/common/embeddable_factory/index.ts index b794ec642f40..68e6c77e9dae 100644 --- a/x-pack/plugins/lens/common/embeddable_factory/index.ts +++ b/x-pack/plugins/lens/common/embeddable_factory/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { cloneDeep } from 'lodash'; import type { SerializableRecord, Serializable } from '@kbn/utility-types'; import type { SavedObjectReference } from '@kbn/core/types'; import type { @@ -17,7 +18,8 @@ export type LensEmbeddablePersistableState = EmbeddableStateWithType & { }; export const inject: EmbeddableRegistryDefinition['inject'] = (state, references) => { - const typedState = state as LensEmbeddablePersistableState; + // We need to clone the state because we can not modify the original state object. + const typedState = cloneDeep(state) as LensEmbeddablePersistableState; if ('attributes' in typedState && typedState.attributes !== undefined) { // match references based on name, so only references associated with this lens panel are injected. diff --git a/x-pack/plugins/ml/public/application/components/custom_urls/custom_url_editor/utils.ts b/x-pack/plugins/ml/public/application/components/custom_urls/custom_url_editor/utils.ts index c154abb6f5f6..d2dff81c3262 100644 --- a/x-pack/plugins/ml/public/application/components/custom_urls/custom_url_editor/utils.ts +++ b/x-pack/plugins/ml/public/application/components/custom_urls/custom_url_editor/utils.ts @@ -285,13 +285,12 @@ async function buildDashboardUrlFromSettings( let query; // Override with filters and queries from saved dashboard if they are available. - const searchSourceJSON = dashboard.attributes.kibanaSavedObjectMeta.searchSourceJSON; - if (searchSourceJSON !== undefined) { - const searchSourceData = JSON.parse(searchSourceJSON); - if (Array.isArray(searchSourceData.filter) && searchSourceData.filter.length > 0) { - filters = searchSourceData.filter; + const { searchSource } = dashboard.attributes.kibanaSavedObjectMeta; + if (searchSource !== undefined) { + if (Array.isArray(searchSource.filter) && searchSource.filter.length > 0) { + filters = searchSource.filter; } - query = searchSourceData.query; + query = searchSource.query; } const queryFromEntityFieldNames = buildAppStateQueryParam(queryFieldNames ?? []); diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx index 739fe2634289..5f2f91df4423 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/service_dashboards/actions/save_dashboard_modal.tsx @@ -23,7 +23,6 @@ import { EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { DashboardItem } from '@kbn/dashboard-plugin/common/content_management'; import { callApmApi } from '../../../../services/rest/create_call_apm_api'; import { useDashboardFetcher } from '../../../../hooks/use_dashboards_fetcher'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; @@ -76,7 +75,7 @@ export function SaveDashboardModal({ const isEditMode = !!currentDashboard?.id; - const options = allAvailableDashboards?.map((dashboardItem: DashboardItem) => ({ + const options = allAvailableDashboards?.map((dashboardItem) => ({ label: dashboardItem.attributes.title, value: dashboardItem.id, disabled: diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/save_dashboard_modal.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/save_dashboard_modal.tsx index 87ff45ef2173..5bd6cc0e1fc6 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/save_dashboard_modal.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/actions/save_dashboard_modal.tsx @@ -23,7 +23,6 @@ import { useEuiTheme, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { DashboardItem } from '@kbn/dashboard-plugin/common/content_management'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { FETCH_STATUS } from '../../../../../hooks/use_fetcher'; import type { @@ -76,7 +75,7 @@ export function SaveDashboardModal({ const options = useMemo( () => - allAvailableDashboards?.map((dashboardItem: DashboardItem) => ({ + allAvailableDashboards?.map((dashboardItem) => ({ label: dashboardItem.attributes.title, value: dashboardItem.id, disabled: diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboards.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboards.tsx index f90aa500c926..694fe5cd1ed2 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboards.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/dashboards/dashboards.tsx @@ -24,7 +24,7 @@ import { DashboardRenderer, } from '@kbn/dashboard-plugin/public'; -import type { DashboardItem } from '@kbn/dashboard-plugin/common/content_management'; +import type { DashboardSearchOut } from '@kbn/dashboard-plugin/server/content_management'; import type { SerializableRecord } from '@kbn/utility-types'; import { ASSET_DETAILS_FLYOUT_LOCATOR_ID, @@ -93,7 +93,7 @@ export function Dashboards() { }, [asset.type, currentDashboard, telemetry, trackingEventProperties]); useEffect(() => { - const allAvailableDashboardsMap = new Map(); + const allAvailableDashboardsMap = new Map(); allAvailableDashboards.forEach((availableDashboard) => { allAvailableDashboardsMap.set(availableDashboard.id, availableDashboard); }); diff --git a/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts b/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts index e7a3ebf9a7f1..dda4a6af5d22 100644 --- a/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts +++ b/x-pack/plugins/security_solution/server/lib/dashboards/routes/get_dashboards_by_tags.ts @@ -7,7 +7,7 @@ import type { Logger } from '@kbn/core/server'; import { i18n } from '@kbn/i18n'; -import type { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import type { DashboardSavedObjectAttributes } from '@kbn/dashboard-plugin/server'; import { transformError } from '@kbn/securitysolution-es-utils'; import { INTERNAL_DASHBOARDS_URL } from '../../../../common/constants'; import type { SecuritySolutionPluginRouter } from '../../../types'; @@ -36,7 +36,7 @@ export const getDashboardsByTagsRoute = (router: SecuritySolutionPluginRouter, l const { tagIds } = request.body; try { - const dashboardsResponse = await savedObjectsClient.find({ + const dashboardsResponse = await savedObjectsClient.find({ type: 'dashboard', hasReference: tagIds.map((id) => ({ id, type: 'tag' })), }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts index ee5a3471a2d3..d54ccec0656e 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/create_gen_ai_dashboard.ts @@ -4,9 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; -import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import type { DashboardSavedObjectAttributes } from '@kbn/dashboard-plugin/server'; import { Logger } from '@kbn/logging'; import { getDashboard } from './gen_ai_dashboard'; @@ -30,7 +30,7 @@ export const initDashboard = async ({ error?: OutputError; }> => { try { - await savedObjectsClient.get('dashboard', dashboardId); + await savedObjectsClient.get('dashboard', dashboardId); return { success: true, }; @@ -50,7 +50,7 @@ export const initDashboard = async ({ } try { - await savedObjectsClient.create( + await savedObjectsClient.create( 'dashboard', getDashboard(genAIProvider, dashboardId).attributes, { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts index 5805dd7728cc..efe5fc0c0ca6 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/gen_ai/gen_ai_dashboard.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import type { DashboardSavedObjectAttributes } from '@kbn/dashboard-plugin/server'; import { v4 as uuidv4 } from 'uuid'; import { SavedObject } from '@kbn/core-saved-objects-common/src/server_types'; import { OPENAI_TITLE, OPENAI_CONNECTOR_ID } from '../../../../common/openai/constants'; @@ -21,7 +21,7 @@ export const getDashboardTitle = (title: string) => `${title} Token Usage`; export const getDashboard = ( genAIProvider: 'OpenAI' | 'Bedrock' | 'Gemini' | 'Inference', dashboardId: string -): SavedObject => { +): SavedObject => { let attributes = { provider: OPENAI_TITLE, dashboardTitle: getDashboardTitle(OPENAI_TITLE),