diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 28b857c3d6ac0..8f8f9ce8402c4 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -168,7 +168,7 @@ describe('checking migration metadata changes on all registered SO types', () => "risk-engine-configuration": "9d54f733fb2bd08978c7059d71e77741574dc2616823745501742d34816a408c", "rules-settings": "8a2c9bb693534c347d2a8f99fa50e6e8d3ef0abb567a53284a7545d58f250903", "sample-data-telemetry": "4c102e89bdcaee1ccc887d1709c7e176c05f25b4c5ac14c3d013b58fbfd806ac", - "search": "b2ce1e4bfa6b0bc7f6170bdf49013ad05b99e4e57d110d6371a80861efe2ce66", + "search": "1df1f6389931c8f004f26be006c70bec95a06a790f3b25fbc601b47dfb03f50b", "search-session": "7648bc4e0f7030ea596ab20690f2b6256ce071a206dea4912a00363737f10ba6", "search-telemetry": "c152fc7e66d5ac7907e81c0926be9c219a15181e10b418b2fbb86bab2760627c", "search_playground": "4b8d7d2bc55cafc3ff7ad479d8d1283568f3edab502d048c49513c3be33e41bc", @@ -1116,14 +1116,14 @@ describe('checking migration metadata changes on all registered SO types', () => "search|global: ce649a79d99c5ff5eb68d544635428ef87946d84", "search|mappings: 432d4dfdb5a33ce29d00ccdcfcda70d7c5f94b52", "search|schemas: 8d6477e08dfdf20335752a69994646f9da90741f", - "search|10.12.0: 7442d4976ccce79029a1019106a6795d0f21e3232e4c9d1e51eed7bf165f1298", - "search|10.11.0: 5bc01080ab7e1843af8012fefa4b6360e0b084f3958db79fc4635bda052ce04b", - "search|10.10.0: 45de2f1aac057a1115e9e6cfbeaf3ba392939f5f5df85519712eae07e93a20e2", - "search|10.9.0: 4235a3d4c888ecfcb8f6e006c8f07f737ef3123496a3215497b7b3112eca0112", - "search|10.8.0: d3a125618c5f17de1514e3b69d9575e563a99746aea9e0085f568e325f20305a", - "search|10.7.0: 55810e03d060c87c13f34d0f14e6e0ef03fdbff89ad30c504496f1a196fd2d25", - "search|10.6.0: a76d1a1200c9279cf21e5752663c4e22eb683473d3eafd5e6ef656f1cbd90e8d", - "search|10.5.0: b3f27b015d8987732f53e695bbc8a7c337448aee7f927a0621e7cab09e15abcd", + "search|10.12.0: 3ce6b6ff77eab9595c76c6430b6bea4e8f29dc4b5a2635da5a9fd8e814b200ea", + "search|10.11.0: 89719bc8fabbea6ae39c7e50b03ea2584086e116082b7f79f9cc6303c53f1f36", + "search|10.10.0: ba62a15ec99c9f8665965cf621da158300f400e7c2a4d6eb6b4077b4c4eec5e1", + "search|10.9.0: 0b932544700ea6a1186b263c4593adf6c6f7c257de702efd6fcb24ba0df67c4c", + "search|10.8.0: 266a781eb6b3a3f0b2ee06304db0ac7828991b370ed1f13b181f7719ea9b7afc", + "search|10.7.0: 2b5c1d095d1156b2c187aecd79215110f8ceb66edf5a9ce6c3e6db2bc56bf3a6", + "search|10.6.0: 7a3f96df2caa6871c0ad18b0c093ce761f47fe7fe147f72ecf4cddeca9442df0", + "search|10.5.0: bae8b969e4a8f8f4511b40cfc24a59f3eef505a8f272b04979f26a4db934c3c4", "search|10.4.0: 770d4bf717afcb7034b36222904d5d8d8c6358a820f3aebdf1691a24bf9f844d", "search|10.3.0: 9bc63a0504674773c55800b17782ad3de232f423f1ba94cdeb1c7c825c562ab8", "search|10.2.0: 4a62769d2137b7e415fec5262af8385989c1fed12ce9dd17249fbb661617d879", diff --git a/src/platform/packages/shared/as-code/data-views-schema/index.ts b/src/platform/packages/shared/as-code/data-views-schema/index.ts index 6dc125a70ce28..f4da2de98fbfb 100644 --- a/src/platform/packages/shared/as-code/data-views-schema/index.ts +++ b/src/platform/packages/shared/as-code/data-views-schema/index.ts @@ -7,5 +7,16 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +export { AS_CODE_DATA_VIEW_REFERENCE_TYPE, AS_CODE_DATA_VIEW_SPEC_TYPE } from './src/constants'; +export { + dataViewReferenceSchema, + dataViewSchema, + dataViewSpecSchema, +} from './src/schema_data_view'; export { runtimeFieldSchema } from './src/schema_runtime_field'; -export type { AsCodeRuntimeField } from './src/types'; +export type { + AsCodeDataView, + AsCodeDataViewReference, + AsCodeDataViewSpec, + AsCodeRuntimeField, +} from './src/types'; diff --git a/src/platform/packages/shared/as-code/data-views-schema/src/constants.ts b/src/platform/packages/shared/as-code/data-views-schema/src/constants.ts new file mode 100644 index 0000000000000..b5c830ae101f1 --- /dev/null +++ b/src/platform/packages/shared/as-code/data-views-schema/src/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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". + */ + +/** `type` discriminator for as-code classic-tab `data_source`: saved Kibana data view id. */ +export const AS_CODE_DATA_VIEW_REFERENCE_TYPE = 'data_view_reference' as const; + +/** `type` discriminator for as-code classic-tab `data_source`: inline DataViewSpec-shaped fields. */ +export const AS_CODE_DATA_VIEW_SPEC_TYPE = 'data_view_spec' as const; diff --git a/src/platform/packages/shared/as-code/data-views-schema/src/schema_data_view.ts b/src/platform/packages/shared/as-code/data-views-schema/src/schema_data_view.ts new file mode 100644 index 0000000000000..daac9edb4bdda --- /dev/null +++ b/src/platform/packages/shared/as-code/data-views-schema/src/schema_data_view.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 { schema } from '@kbn/config-schema'; +import { AS_CODE_DATA_VIEW_REFERENCE_TYPE, AS_CODE_DATA_VIEW_SPEC_TYPE } from './constants'; +import { runtimeFieldSchema } from './schema_runtime_field'; + +export const dataViewReferenceSchema = schema.object( + { + type: schema.literal(AS_CODE_DATA_VIEW_REFERENCE_TYPE), + id: schema.string({ + meta: { + description: + 'The id of the Kibana data view to use as the data source. Example: "my-data-view".', + }, + }), + }, + { meta: { id: 'dataViewReferenceDataSourceTypeSchema' } } +); + +export const dataViewSpecSchema = schema.object( + { + type: schema.literal(AS_CODE_DATA_VIEW_SPEC_TYPE), + index_pattern: schema.string({ + meta: { + description: + 'The index pattern (Elasticsearch index expression) to use as the data source. Example: "my-index-*".', + }, + }), + time_field: schema.maybe( + schema.string({ + meta: { + description: + 'The name of the time field in the index. Used for time-based filtering. Example: "@timestamp".', + }, + }) + ), + runtime_fields: schema.maybe(schema.arrayOf(runtimeFieldSchema, { maxSize: 100 })), + }, + { meta: { id: 'dataViewSpecDataSourceTypeSchema' } } +); + +export const dataViewSchema = schema.discriminatedUnion('type', [ + dataViewReferenceSchema, + dataViewSpecSchema, +]); diff --git a/src/platform/packages/shared/as-code/data-views-schema/src/types.ts b/src/platform/packages/shared/as-code/data-views-schema/src/types.ts index db0c06d1adcba..08179c26beef6 100644 --- a/src/platform/packages/shared/as-code/data-views-schema/src/types.ts +++ b/src/platform/packages/shared/as-code/data-views-schema/src/types.ts @@ -7,6 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ import type { TypeOf } from '@kbn/config-schema'; +import type { + dataViewReferenceSchema, + dataViewSchema, + dataViewSpecSchema, +} from './schema_data_view'; import type { runtimeFieldSchema } from './schema_runtime_field'; export type AsCodeRuntimeField = TypeOf; +export type AsCodeDataViewReference = TypeOf; +export type AsCodeDataViewSpec = TypeOf; +export type AsCodeDataView = TypeOf; diff --git a/src/platform/packages/shared/as-code/data-views-transforms/index.ts b/src/platform/packages/shared/as-code/data-views-transforms/index.ts index c42445ec37ad2..f27f8212cbd8c 100644 --- a/src/platform/packages/shared/as-code/data-views-transforms/index.ts +++ b/src/platform/packages/shared/as-code/data-views-transforms/index.ts @@ -7,5 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +export * from './src/from_stored_data_view'; export * from './src/from_stored_runtime_fields'; +export * from './src/to_stored_data_view'; export * from './src/to_stored_runtime_fields'; diff --git a/src/platform/packages/shared/as-code/data-views-transforms/src/from_stored_data_view.ts b/src/platform/packages/shared/as-code/data-views-transforms/src/from_stored_data_view.ts new file mode 100644 index 0000000000000..3cdff6ea50cfd --- /dev/null +++ b/src/platform/packages/shared/as-code/data-views-transforms/src/from_stored_data_view.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { DataViewSpec } from '@kbn/data-views-plugin/common'; +import { + AS_CODE_DATA_VIEW_REFERENCE_TYPE, + AS_CODE_DATA_VIEW_SPEC_TYPE, + type AsCodeDataView, +} from '@kbn/as-code-data-views-schema'; +import { fromStoredRuntimeFields } from './from_stored_runtime_fields'; + +/** + * Convert a stored search-source `index` value (saved object / serialized search source) + * to the as-code data view shape. + * + * @param index String id (referenced data view), inline {@link DataViewSpec}, or null/undefined + * @returns As-code `data_source` object for classic (KQL/Lucene) tabs + */ +export function fromStoredDataView( + index: string | DataViewSpec | null | undefined +): AsCodeDataView { + if (!index) throw new Error('Cannot derive data view from empty index'); + if (typeof index === 'string') { + return { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: index }; + } + if (!index.title) throw new Error('Cannot derive data view without `title` or `id`'); + return { + type: AS_CODE_DATA_VIEW_SPEC_TYPE, + index_pattern: index.title, + time_field: index.timeFieldName, + runtime_fields: fromStoredRuntimeFields( + index.runtimeFieldMap, + index.fieldFormats, + index.fieldAttrs + ), + }; +} diff --git a/src/platform/packages/shared/as-code/data-views-transforms/src/stored_data_source.test.ts b/src/platform/packages/shared/as-code/data-views-transforms/src/stored_data_source.test.ts new file mode 100644 index 0000000000000..d3e61ca134981 --- /dev/null +++ b/src/platform/packages/shared/as-code/data-views-transforms/src/stored_data_source.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { + AS_CODE_DATA_VIEW_REFERENCE_TYPE, + AS_CODE_DATA_VIEW_SPEC_TYPE, + type AsCodeDataViewReference, + type AsCodeDataViewSpec, +} from '@kbn/as-code-data-views-schema'; +import { toStoredDataView } from './to_stored_data_view'; + +describe('toStoredDataView', () => { + it('converts data_view_reference data_source to string id', () => { + const dataView: AsCodeDataViewReference = { + type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, + id: 'my-data-view-id', + }; + const result = toStoredDataView(dataView); + expect(result).toBe('my-data-view-id'); + }); + + it('converts index-pattern data_source to serialized index spec', () => { + const dataView: AsCodeDataViewSpec = { + type: AS_CODE_DATA_VIEW_SPEC_TYPE, + index_pattern: 'my-index-*', + time_field: '@timestamp', + runtime_fields: [ + { + name: 'rt', + type: 'keyword', + script: 'emit(doc["id"].value)', + format: { type: 'string' }, + }, + ], + }; + const result = toStoredDataView(dataView); + expect(result).toEqual({ + title: 'my-index-*', + timeFieldName: '@timestamp', + fieldFormats: { + rt: { id: 'string', params: undefined }, + }, + fieldAttrs: { + rt: {}, + }, + runtimeFieldMap: { + rt: { + type: 'keyword', + script: { source: 'emit(doc["id"].value)' }, + }, + }, + }); + }); + + it('converts index-pattern data_source without runtime fields', () => { + const dataView: AsCodeDataViewSpec = { + type: AS_CODE_DATA_VIEW_SPEC_TYPE, + index_pattern: 'logs-*', + time_field: '@timestamp', + }; + const result = toStoredDataView(dataView); + expect(result).toEqual({ + title: 'logs-*', + timeFieldName: '@timestamp', + }); + }); +}); diff --git a/src/platform/packages/shared/as-code/data-views-transforms/src/to_stored_data_view.ts b/src/platform/packages/shared/as-code/data-views-transforms/src/to_stored_data_view.ts new file mode 100644 index 0000000000000..39ea77da884e2 --- /dev/null +++ b/src/platform/packages/shared/as-code/data-views-transforms/src/to_stored_data_view.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { DataViewSpec } from '@kbn/data-views-plugin/common'; +import { + AS_CODE_DATA_VIEW_REFERENCE_TYPE, + type AsCodeDataView, +} from '@kbn/as-code-data-views-schema'; +import { + toStoredFieldAttributes, + toStoredFieldFormats, + toStoredRuntimeFields, +} from './to_stored_runtime_fields'; + +/** + * Convert an as-code data view back to a stored search-source `index` value + * (string id for a referenced data view, or inline {@link DataViewSpec} fields). + * + * @param dataView As-code `data_source` value from classic tab state + * @returns Value suitable for `SerializedSearchSourceFields.index` + */ +export function toStoredDataView(dataView: AsCodeDataView): string | DataViewSpec { + if (dataView.type === AS_CODE_DATA_VIEW_REFERENCE_TYPE) return dataView.id; + const runtimeFieldMap = toStoredRuntimeFields(dataView.runtime_fields); + const fieldFormats = toStoredFieldFormats(dataView.runtime_fields); + const fieldAttrs = toStoredFieldAttributes(dataView.runtime_fields); + return { + title: dataView.index_pattern, + timeFieldName: dataView.time_field, + ...(runtimeFieldMap && Object.keys(runtimeFieldMap).length > 0 && { runtimeFieldMap }), + ...(fieldFormats && Object.keys(fieldFormats).length > 0 && { fieldFormats }), + ...(fieldAttrs && Object.keys(fieldAttrs).length > 0 && { fieldAttrs }), + }; +} diff --git a/src/platform/packages/shared/kbn-saved-search-component/src/components/saved_search.tsx b/src/platform/packages/shared/kbn-saved-search-component/src/components/saved_search.tsx index 4b4f36b6729f9..ea7e212b9cea4 100644 --- a/src/platform/packages/shared/kbn-saved-search-component/src/components/saved_search.tsx +++ b/src/platform/packages/shared/kbn-saved-search-component/src/components/saved_search.tsx @@ -12,7 +12,10 @@ import { debounceTime, distinctUntilChanged, map } from 'rxjs'; import { isEqual } from 'lodash'; import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { SEARCH_EMBEDDABLE_TYPE, getDefaultSort } from '@kbn/discover-utils'; -import type { SearchEmbeddableApi } from '@kbn/discover-plugin/public'; +import { + type SearchEmbeddableApi, + type SearchEmbeddablePanelApiState, +} from '@kbn/discover-plugin/public'; import type { SearchEmbeddableState } from '@kbn/discover-plugin/common'; import { css } from '@emotion/react'; import { type SavedSearch, toSavedSearchAttributes } from '@kbn/saved-search-plugin/common'; @@ -268,7 +271,7 @@ const SavedSearchComponentTable: React.FC< ); return ( - + maybeId={undefined} type={SEARCH_EMBEDDABLE_TYPE} getParentApi={() => parentApi} diff --git a/src/platform/plugins/shared/discover/common/constants.ts b/src/platform/plugins/shared/discover/common/constants.ts index 0298b0a09656d..588d8e1ee340b 100644 --- a/src/platform/plugins/shared/discover/common/constants.ts +++ b/src/platform/plugins/shared/discover/common/constants.ts @@ -48,3 +48,10 @@ export const TAB_STATE_URL_KEY = '_tab'; // `_t` is already used by Kibana for t */ export const TRACES_PRODUCT_FEATURE_ID = 'discover:traces'; export const METRICS_EXPERIENCE_PRODUCT_FEATURE_ID = 'discover:metrics-experience'; + +/** + * When enabled, Discover search embeddable uses transformIn/transformOut to convert between + * API format (DiscoverSessionEmbeddableState) and stored format (StoredSearchEmbeddableState). + * When disabled, panel state is stored and loaded as-is (pre-transform behavior). + */ +export const EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY = 'discover.embeddableTransforms'; diff --git a/src/platform/plugins/shared/discover/common/embeddable/constants.ts b/src/platform/plugins/shared/discover/common/embeddable/constants.ts index 97937f61b5735..e19910e059180 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/constants.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/constants.ts @@ -9,6 +9,19 @@ import type { SavedSearchAttributes } from '@kbn/saved-search-plugin/common'; +/** Reference name used for the saved search saved object when the embeddable is by-reference */ +export const SAVED_SEARCH_SAVED_OBJECT_REF_NAME = 'savedObjectRef'; + +/** + * Used for search embeddable transforms. The as-code API shape does not support tab id/label. When + * transforming from the as-code API shape back to the stored shape, we use these synthetic values + * to satisfy the stored shape/types. + */ +export const DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_ID = + 'discover_session_embeddable_synthetic_tab_id'; +export const DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_LABEL = + 'discover_session_embeddable_synthetic_tab_label'; + /** This constant refers to the parts of the saved search state that can be edited from a dashboard */ export const EDITABLE_SAVED_SEARCH_KEYS = [ 'sort', diff --git a/src/platform/plugins/shared/discover/common/embeddable/get_transform_in.ts b/src/platform/plugins/shared/discover/common/embeddable/get_transform_in.ts index 68761d3ec75f5..820182ea0c3de 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/get_transform_in.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/get_transform_in.ts @@ -7,80 +7,83 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SavedSearchType } from '@kbn/saved-search-plugin/common'; import type { SavedObjectReference } from '@kbn/core/server'; import { extractReferences, parseSearchSourceJSON } from '@kbn/data-plugin/common'; import type { DrilldownTransforms } from '@kbn/embeddable-plugin/common'; +import { SavedSearchType } from '@kbn/saved-search-plugin/common'; +import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; +import { isSearchEmbeddableByValueState, isSearchEmbeddableLegacyPanelState } from './type_guards'; +import { toStoredSearchEmbeddable } from './transform_utils'; import type { - SearchEmbeddableByReferenceState, + SearchEmbeddablePanelApiState, SearchEmbeddableState, StoredSearchEmbeddableState, } from './types'; -export const SAVED_SEARCH_SAVED_OBJECT_REF_NAME = 'savedObjectRef'; - -function isByRefState(state: SearchEmbeddableState): state is SearchEmbeddableByReferenceState { - return 'savedObjectId' in state; -} - export function getTransformIn(transformDrilldownsIn: DrilldownTransforms['transformIn']) { - function transformIn(state: SearchEmbeddableState): { + return function transformIn(apiState: SearchEmbeddablePanelApiState): { state: StoredSearchEmbeddableState; references: SavedObjectReference[]; } { - const { state: storedState, references: drilldownReferences } = - transformDrilldownsIn(state); + const { state, references } = transformDrilldownsIn(apiState); + return isSearchEmbeddableLegacyPanelState(state) + ? legacyTransformIn(state, references) + : toStoredSearchEmbeddable(state, references); + }; +} + +function legacyTransformIn( + storedState: SearchEmbeddableState, + drilldownReferences: SavedObjectReference[] = [] +): { state: StoredSearchEmbeddableState; references: SavedObjectReference[] } { + if (!isSearchEmbeddableByValueState(storedState)) { + const { savedObjectId, ...rest } = storedState; + return { + state: rest, + references: [ + { + name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, + type: SavedSearchType, + id: savedObjectId, + }, + ...drilldownReferences, + ], + }; + } - if (isByRefState(storedState)) { - const { savedObjectId, ...rest } = storedState; + // by value + const tabReferences: SavedObjectReference[] = []; + const tabs = storedState.attributes.tabs.map((tab) => { + try { + const searchSourceValues = parseSearchSourceJSON( + tab.attributes.kibanaSavedObjectMeta.searchSourceJSON + ); + const [searchSourceFields, searchSourceReferences] = extractReferences(searchSourceValues); + tabReferences.push(...searchSourceReferences); return { - state: rest, - references: [ - { - name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, - type: SavedSearchType, - id: savedObjectId, + ...tab, + attributes: { + ...tab.attributes, + kibanaSavedObjectMeta: { + ...tab.attributes.kibanaSavedObjectMeta, + searchSourceJSON: JSON.stringify(searchSourceFields), }, - ...drilldownReferences, - ], + }, }; + } catch (e) { + return tab; } + }); - // by value - const tabReferences: SavedObjectReference[] = []; - const tabs = storedState.attributes.tabs.map((tab) => { - try { - const searchSourceValues = parseSearchSourceJSON( - tab.attributes.kibanaSavedObjectMeta.searchSourceJSON - ); - const [searchSourceFields, searchSourceReferences] = extractReferences(searchSourceValues); - tabReferences.push(...searchSourceReferences); - return { - ...tab, - attributes: { - ...tab.attributes, - kibanaSavedObjectMeta: { - ...tab.attributes.kibanaSavedObjectMeta, - searchSourceJSON: JSON.stringify(searchSourceFields), - }, - }, - }; - } catch (e) { - return tab; - } - }); - - const { references = [], ...otherAttrs } = storedState.attributes; - return { - state: { - ...storedState, - attributes: { - ...otherAttrs, - tabs, - }, + const { references = [], ...otherAttrs } = storedState.attributes; + return { + state: { + ...storedState, + attributes: { + ...otherAttrs, + tabs, }, - references: [...references, ...tabReferences, ...drilldownReferences], - }; - } - return transformIn; + }, + references: [...references, ...tabReferences, ...drilldownReferences], + }; } diff --git a/src/platform/plugins/shared/discover/common/embeddable/get_transform_out.ts b/src/platform/plugins/shared/discover/common/embeddable/get_transform_out.ts index 27bfcdaa99d17..6fcb67491edc6 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/get_transform_out.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/get_transform_out.ts @@ -7,80 +7,83 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { flow, omit } from 'lodash'; -import { extractTabs, SavedSearchType } from '@kbn/saved-search-plugin/common'; +import type { SavedObjectReference } from '@kbn/core/server'; import { injectReferences, parseSearchSourceJSON } from '@kbn/data-plugin/common'; import type { DrilldownTransforms } from '@kbn/embeddable-plugin/common'; -import type { SavedObjectReference } from '@kbn/core/server'; +import { flow, omit } from 'lodash'; import { transformTimeRangeOut, transformTitlesOut } from '@kbn/presentation-publishing'; +import { extractTabs, SavedSearchType } from '@kbn/saved-search-plugin/common'; +import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; import type { - SearchEmbeddableByReferenceState, - SearchEmbeddableByValueState, - StoredSearchEmbeddableByValueState, + SearchEmbeddablePanelApiState, + SearchEmbeddableState, StoredSearchEmbeddableState, } from './types'; -import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './get_transform_in'; - -function isByValue( - state: StoredSearchEmbeddableState -): state is StoredSearchEmbeddableByValueState { - return ( - typeof (state as StoredSearchEmbeddableByValueState).attributes === 'object' && - (state as StoredSearchEmbeddableByValueState).attributes !== null - ); -} +import { isSearchEmbeddableByValueState } from './type_guards'; +import { fromStoredSearchEmbeddable } from './transform_utils'; -export function getTransformOut(transformDrilldownsOut: DrilldownTransforms['transformOut']) { - function transformOut( +export function getTransformOut( + transformDrilldownsOut: DrilldownTransforms['transformOut'], + isEmbeddableTransformsEnabled: () => boolean +) { + return function transformOut( storedState: StoredSearchEmbeddableState, references?: SavedObjectReference[] - ) { + ): SearchEmbeddablePanelApiState { const transformsFlow = flow( transformTitlesOut, transformTimeRangeOut, (state: StoredSearchEmbeddableState) => transformDrilldownsOut(state, references) ); const state = transformsFlow(storedState); + return !isEmbeddableTransformsEnabled() + ? legacyTransformOut(state, references) + : fromStoredSearchEmbeddable(state, references); + }; +} - if (isByValue(state)) { - const tabsState = { ...state, attributes: extractTabs(state.attributes) }; - const tabs = tabsState.attributes.tabs.map((tab) => { - try { - const searchSourceValues = parseSearchSourceJSON( - tab.attributes.kibanaSavedObjectMeta.searchSourceJSON - ); - const searchSourceFields = injectReferences(searchSourceValues, references ?? []); - return { - ...tab, - attributes: { - ...omit(tab.attributes, 'references'), - kibanaSavedObjectMeta: { - ...tab.attributes.kibanaSavedObjectMeta, - searchSourceJSON: JSON.stringify(searchSourceFields), - }, +function legacyTransformOut( + state: StoredSearchEmbeddableState, + references: SavedObjectReference[] | undefined +): SearchEmbeddableState { + if (isSearchEmbeddableByValueState(state)) { + const tabsState = { ...state, attributes: extractTabs(state.attributes) }; + const tabs = tabsState.attributes.tabs.map((tab) => { + try { + const searchSourceValues = parseSearchSourceJSON( + tab.attributes.kibanaSavedObjectMeta.searchSourceJSON + ); + const searchSourceFields = injectReferences(searchSourceValues, references ?? []); + return { + ...tab, + attributes: { + ...omit(tab.attributes, 'references'), + kibanaSavedObjectMeta: { + ...tab.attributes.kibanaSavedObjectMeta, + searchSourceJSON: JSON.stringify(searchSourceFields), }, - }; - } catch (e) { - return tab; - } - }); - - return { - ...state, - attributes: { - ...state.attributes, - tabs, - }, - } as SearchEmbeddableByValueState; - } + }, + }; + } catch (e) { + return tab; + } + }); - const savedObjectRef = (references ?? []).find( - (ref) => SavedSearchType === ref.type && ref.name === SAVED_SEARCH_SAVED_OBJECT_REF_NAME - ); return { ...state, - ...(savedObjectRef?.id ? { savedObjectId: savedObjectRef.id } : {}), - } as SearchEmbeddableByReferenceState; + attributes: { + ...state.attributes, + tabs, + }, + }; } - return transformOut; + + const savedObjectRef = (references ?? []).find( + (ref) => SavedSearchType === ref.type && ref.name === SAVED_SEARCH_SAVED_OBJECT_REF_NAME + ); + if (!savedObjectRef) throw new Error(`Missing reference of type "${SavedSearchType}"`); + return { + ...state, + savedObjectId: savedObjectRef.id, + }; } diff --git a/src/platform/plugins/shared/discover/common/embeddable/index.ts b/src/platform/plugins/shared/discover/common/embeddable/index.ts index b383982938a5c..76c672a134f53 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/index.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/index.ts @@ -7,4 +7,23 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { getSearchEmbeddableTransforms } from './search_embeddable_transforms'; +export { + DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_ID, + DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_LABEL, +} from './constants'; +export { + getSearchEmbeddableTransforms, + type SearchEmbeddablePanelApiState, +} from './search_embeddable_transforms'; +export { + isDiscoverSessionEmbeddableByReferenceState, + isSearchEmbeddableLegacyPanelState, +} from './type_guards'; +export { + fromStoredSearchEmbeddable, + fromStoredSearchEmbeddableByRef, + fromStoredSearchEmbeddableByValue, + toStoredSearchEmbeddable, + toStoredSearchEmbeddableByValue, + fromDiscoverSessionPanelOverrides, +} from './transform_utils'; diff --git a/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.test.ts b/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.test.ts index 0f16a4fc1214b..c5845e7f1ebfd 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.test.ts @@ -7,18 +7,27 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { AS_CODE_DATA_VIEW_REFERENCE_TYPE } from '@kbn/as-code-data-views-schema'; import type { DrilldownTransforms } from '@kbn/embeddable-plugin/common'; import { getSearchEmbeddableTransforms } from './search_embeddable_transforms'; import type { - SearchEmbeddableByValueState, + SearchEmbeddableState, StoredSearchEmbeddableByValueState, StoredSearchEmbeddableState, - SearchEmbeddableByReferenceState, - SearchEmbeddableState, } from './types'; +import type { + DiscoverSessionClassicTab, + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableByValueState, + DiscoverSessionEmbeddableState, +} from '../../server'; +import { SavedSearchType } from '@kbn/saved-search-plugin/common'; +import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; +import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; +import { DataGridDensity } from '@kbn/discover-utils'; const mockDrilldownTransforms = { - transformIn: jest.fn().mockImplementation((state: SearchEmbeddableState) => ({ + transformIn: jest.fn().mockImplementation((state: DiscoverSessionEmbeddableState) => ({ state, references: [], })), @@ -29,253 +38,256 @@ describe('searchEmbeddableTransforms', () => { beforeEach(() => { jest.clearAllMocks(); }); + + const whenEnabled = () => true; + const whenDisabled = () => false; + describe('transformOut', () => { - it('returns the state unchanged if attributes are not present', () => { + it('converts by-reference stored state to DiscoverSession API shape', () => { const state: StoredSearchEmbeddableState = { title: 'Test Title', description: 'Test Description', + time_range: { from: 'now-15m', to: 'now' }, }; - const result = getSearchEmbeddableTransforms(mockDrilldownTransforms).transformOut?.(state); - expect(result).toEqual(state); - }); - - it('transforms the state by extracting tabs if attributes are present', () => { - const state = { - title: 'Test Title', - description: 'Test Description', - attributes: { - title: 'Test Title', - description: 'Test Description', - columns: ['column1', 'column2'], - }, - } as StoredSearchEmbeddableByValueState; const references = [ - { - name: 'ref1', - type: 'type1', - id: 'id1', - }, + { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-123' }, ]; - const expectedAttributes = { + const result = getSearchEmbeddableTransforms( + mockDrilldownTransforms, + whenEnabled + ).transformOut?.(state, references); + expect(result).toEqual({ title: 'Test Title', description: 'Test Description', - columns: ['column1', 'column2'], - tabs: [ - { - id: expect.any(String), - label: 'Untitled', - attributes: { - columns: ['column1', 'column2'], - }, - }, - ], - }; - const result = getSearchEmbeddableTransforms(mockDrilldownTransforms).transformOut?.( - state, - references - ); - expect(result).toEqual({ - ...state, - attributes: expectedAttributes, + time_range: { from: 'now-15m', to: 'now' }, + discover_session_id: 'session-123', + selected_tab_id: undefined, + overrides: {}, }); + expect(mockDrilldownTransforms.transformOut).toHaveBeenCalledWith(state, references); }); - it('handles empty attributes gracefully', () => { - const state = { - title: 'Test Title', - description: 'Test Description', - attributes: {}, - } as StoredSearchEmbeddableByValueState; - const expectedAttributes = { - tabs: [ - { - id: expect.any(String), - label: 'Untitled', - attributes: {}, + it('converts by-value stored state to DiscoverSession API shape with tabs', () => { + const state: StoredSearchEmbeddableByValueState = { + title: 'Panel Title', + description: 'Panel description', + attributes: { + title: '', + description: '', + columns: ['message', '@timestamp'], + sort: [['@timestamp', 'desc']], + grid: { columns: { '@timestamp': { width: 200 } } }, + hideChart: false, + hideTable: false, + isTextBasedQuery: false, + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + index: 'data-view-1', + query: { language: 'kuery', query: '' }, + filter: [], + }), }, - ], + tabs: [ + { + id: 'tab-1', + label: 'Untitled', + attributes: { + columns: ['message', '@timestamp'], + sort: [['@timestamp', 'desc']], + grid: { columns: { '@timestamp': { width: 200 } } }, + hideChart: false, + hideTable: false, + isTextBasedQuery: false, + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + index: 'data-view-1', + query: { language: 'kuery', query: '' }, + filter: [], + }), + }, + }, + }, + ], + }, }; - const result = getSearchEmbeddableTransforms(mockDrilldownTransforms).transformOut?.(state); - expect(result).toEqual({ - ...state, - attributes: expectedAttributes, + const references = [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: 'data-view-1', + }, + ]; + const result = getSearchEmbeddableTransforms( + mockDrilldownTransforms, + whenEnabled + ).transformOut?.(state, references) as DiscoverSessionEmbeddableByValueState; + expect(result.title).toBe('Panel Title'); + expect(result.description).toBe('Panel description'); + expect(result.tabs).toHaveLength(1); + expect(result.tabs[0].column_order).toEqual(['message', '@timestamp']); + expect(result.tabs[0].column_settings).toEqual({ + '@timestamp': { width: 200 }, + }); + const { + sort, + view_mode: viewMode, + density, + data_source: dataSource, + } = result.tabs[0] as DiscoverSessionClassicTab; + expect(sort).toEqual([{ name: '@timestamp', direction: 'desc' }]); + expect(viewMode).toBe(VIEW_MODE.DOCUMENT_LEVEL); + expect(density).toBe(DataGridDensity.COMPACT); + expect(dataSource).toEqual({ + type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, + id: 'data-view-1', }); + expect(mockDrilldownTransforms.transformOut).toHaveBeenCalledWith(state, references); }); - it('transforms drilldowns during transformOut', () => { + + it('calls transformDrilldownsOut with state and references', () => { const state: StoredSearchEmbeddableState = { title: 'Test Title', description: 'Test Description', drilldowns: [], }; - const mockReferences = [{ name: 'enhRef', type: 'dynamicAction', id: 'foo' }]; - const result = getSearchEmbeddableTransforms(mockDrilldownTransforms).transformOut?.( - state, - mockReferences - ); + const mockReferences = [ + { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-xyz' }, + ]; + const result = getSearchEmbeddableTransforms( + mockDrilldownTransforms, + whenEnabled + ).transformOut?.(state, mockReferences); expect(mockDrilldownTransforms.transformOut).toHaveBeenCalledWith(state, mockReferences); - expect(result).toEqual({ - ...state, + expect(result).toMatchObject({ + title: 'Test Title', + description: 'Test Description', + discover_session_id: 'session-xyz', }); }); }); + describe('transformIn', () => { describe('by-reference state', () => { - it('transforms by-reference state', () => { - const serializedState: SearchEmbeddableByReferenceState = { - savedObjectId: 'test-saved-object-id', + it('converts DiscoverSession by-reference API state to stored state with references', () => { + const apiState: DiscoverSessionEmbeddableByReferenceState = { title: 'Test Search', description: 'Test Description', - drilldowns: [], - columns: ['field1', 'field2'], - sort: [['timestamp', 'desc']], + time_range: { from: 'now-15m', to: 'now' }, + discover_session_id: 'test-saved-object-id', + selected_tab_id: undefined, + overrides: {}, }; - const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(serializedState); + const result = getSearchEmbeddableTransforms(mockDrilldownTransforms, whenEnabled) + .transformIn!(apiState); expect(result.state).toEqual({ title: 'Test Search', description: 'Test Description', - columns: ['field1', 'field2'], - sort: [['timestamp', 'desc']], - drilldowns: [], + time_range: { from: 'now-15m', to: 'now' }, }); - expect(result.references).toEqual([ { - name: 'savedObjectRef', - type: 'search', + name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, + type: SavedSearchType, id: 'test-saved-object-id', }, ]); - - expect(mockDrilldownTransforms.transformIn).toHaveBeenCalledWith(serializedState); + expect(mockDrilldownTransforms.transformIn).toHaveBeenCalledWith(apiState); }); - it('handles by-reference state without enhancements', () => { - const serializedState: SearchEmbeddableByReferenceState = { - savedObjectId: 'test-saved-object-id', - title: 'Test Search', - columns: ['field1'], + it('handles by-reference API state with selected_tab_id', () => { + const apiState: DiscoverSessionEmbeddableByReferenceState = { + title: 'My Search', + description: 'My description', + time_range: { from: 'now-1h', to: 'now' }, + discover_session_id: 'session-456', + selected_tab_id: 'tab-1', + overrides: {}, }; - const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(serializedState); + const result = getSearchEmbeddableTransforms(mockDrilldownTransforms, whenEnabled) + .transformIn!(apiState); expect(result.state).toEqual({ - title: 'Test Search', - columns: ['field1'], + title: 'My Search', + description: 'My description', + time_range: { from: 'now-1h', to: 'now' }, + selectedTabId: 'tab-1', }); - expect(result.references).toEqual([ { - name: 'savedObjectRef', - type: 'search', - id: 'test-saved-object-id', + name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, + type: SavedSearchType, + id: 'session-456', }, ]); }); }); describe('by-value state', () => { - it('transforms by-value state', () => { - const serializedState: SearchEmbeddableByValueState = { - attributes: { - title: 'Test Search', - description: 'Test Description', - columns: ['field1', 'field2'], - sort: [], - grid: {}, - hideChart: false, - hideTable: false, - isTextBasedQuery: false, - kibanaSavedObjectMeta: { - searchSourceJSON: '{"query":{"match_all":{}}}', - }, - tabs: [], - }, + it('converts DiscoverSession by-value API state to stored state with references', () => { + const apiState: DiscoverSessionEmbeddableByValueState = { title: 'Panel Title', - }; - - const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(serializedState); - - expect(result.state as StoredSearchEmbeddableByValueState).toEqual(serializedState); - expect(result.references).toEqual([]); - }); - - it('handles by-value state with enhancements', () => { - const serializedState: SearchEmbeddableByValueState = { - attributes: { - title: 'Test Search', - description: 'Test Description', - columns: [], - sort: [], - grid: {}, - hideChart: false, - hideTable: false, - isTextBasedQuery: false, - kibanaSavedObjectMeta: { - searchSourceJSON: '{}', + description: 'Panel description', + tabs: [ + { + column_order: ['message', '@timestamp'], + column_settings: { '@timestamp': { width: 200 } }, + sort: [{ name: '@timestamp', direction: 'desc' }], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 'auto', + row_height: 'auto', + query: { language: 'kuery', query: '' }, + filters: [], + rows_per_page: 100, + sample_size: 1000, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-1' }, }, - tabs: [], - references: [], - }, - drilldowns: [], + ], }; - const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(serializedState); + const result = getSearchEmbeddableTransforms(mockDrilldownTransforms, whenEnabled) + .transformIn!(apiState); - expect(result.references).toEqual([]); - expect(mockDrilldownTransforms.transformIn).toHaveBeenCalledWith(serializedState); + expect(result.references).toContainEqual({ + id: 'data-view-1', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + }); + expect((result.state as StoredSearchEmbeddableByValueState).attributes).toBeDefined(); + expect((result.state as StoredSearchEmbeddableByValueState).attributes.tabs).toHaveLength( + 1 + ); + expect(mockDrilldownTransforms.transformIn).toHaveBeenCalledWith(apiState); }); - it('includes attributes.references so data view ref is stored on dashboard (by-value Classic mode)', () => { + it('includes references so data view ref is stored on dashboard (by-value Classic mode)', () => { const dataViewRef = { name: 'kibanaSavedObjectMeta.searchSourceJSON.index', id: 'data-view-id-123', type: 'index-pattern', }; - const serializedState: SearchEmbeddableByValueState = { - attributes: { - title: 'Test Search', - description: '', - columns: ['_source'], - sort: [], - grid: {}, - hideChart: false, - hideTable: false, - isTextBasedQuery: false, - kibanaSavedObjectMeta: { - searchSourceJSON: '{"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', - }, - tabs: [ - { - id: 'tab-1', - label: 'Tab 1', - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: - '{"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', - }, - sort: [], - columns: ['_source'], - grid: {}, - hideChart: false, - hideTable: false, - sampleSize: 100, - isTextBasedQuery: false, - }, - }, - ], - references: [dataViewRef], - }, + const apiState: DiscoverSessionEmbeddableByValueState = { title: 'Panel Title', + tabs: [ + { + column_order: ['_source'], + sort: [], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 3, + row_height: 3, + query: { language: 'kuery', query: '' }, + filters: [], + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-id-123' }, + }, + ], }; - const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(serializedState); + const result = getSearchEmbeddableTransforms(mockDrilldownTransforms, whenEnabled) + .transformIn!(apiState); expect(result.references).toContainEqual(dataViewRef); expect((result.state as StoredSearchEmbeddableByValueState).attributes).not.toHaveProperty( @@ -284,4 +296,45 @@ describe('searchEmbeddableTransforms', () => { }); }); }); + + describe('when feature flag is disabled (legacy main behavior)', () => { + it('transformIn runs legacy transform: extracts savedObjectId to reference (by-ref)', () => { + const apiState: SearchEmbeddableState = { + title: 'Title', + savedObjectId: 'session-1', + }; + const result = getSearchEmbeddableTransforms(mockDrilldownTransforms, whenDisabled) + .transformIn!(apiState); + expect(mockDrilldownTransforms.transformIn).toHaveBeenCalledWith(apiState); + expect(result.state).not.toHaveProperty('savedObjectId'); + expect(result.references).toContainEqual({ + name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, + type: SavedSearchType, + id: 'session-1', + }); + }); + + it('transformOut runs legacy transform: injects savedObjectId from references (by-ref)', () => { + const storedState: StoredSearchEmbeddableState = { + title: 'Title', + description: 'Description', + time_range: { from: 'now-15m', to: 'now' }, + }; + const references = [ + { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-1' }, + ]; + const result = getSearchEmbeddableTransforms( + mockDrilldownTransforms, + whenDisabled + ).transformOut?.(storedState, references); + expect(mockDrilldownTransforms.transformOut).toHaveBeenCalledWith( + expect.anything(), + references + ); + expect(result).toMatchObject({ + title: 'Title', + savedObjectId: 'session-1', + }); + }); + }); }); diff --git a/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.ts b/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.ts index 79b95922eae6b..dac830a724e7e 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.ts @@ -8,15 +8,18 @@ */ import type { DrilldownTransforms, EmbeddableTransforms } from '@kbn/embeddable-plugin/common'; -import type { SearchEmbeddableState, StoredSearchEmbeddableState } from './types'; +import type { SearchEmbeddablePanelApiState, StoredSearchEmbeddableState } from './types'; import { getTransformIn } from './get_transform_in'; import { getTransformOut } from './get_transform_out'; +export type { SearchEmbeddablePanelApiState } from './types'; + export function getSearchEmbeddableTransforms( - drilldownTransforms: DrilldownTransforms -): EmbeddableTransforms { + drilldownTransforms: DrilldownTransforms, + isEmbeddableTransformsEnabled: () => boolean +): EmbeddableTransforms { return { transformIn: getTransformIn(drilldownTransforms.transformIn), - transformOut: getTransformOut(drilldownTransforms.transformOut), + transformOut: getTransformOut(drilldownTransforms.transformOut, isEmbeddableTransformsEnabled), }; } diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts new file mode 100644 index 0000000000000..866d5dc4c52c7 --- /dev/null +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -0,0 +1,1070 @@ +/* + * 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 { + AS_CODE_DATA_VIEW_REFERENCE_TYPE, + AS_CODE_DATA_VIEW_SPEC_TYPE, +} from '@kbn/as-code-data-views-schema'; +import type { SavedObjectReference } from '@kbn/core-saved-objects-common/src/server_types'; +import { + fromStoredSearchEmbeddable, + fromStoredSearchEmbeddableByRef, + fromStoredSearchEmbeddableByValue, + fromStoredGrid, + fromStoredHeight, + toDiscoverSessionPanelOverrides, + fromStoredSort, + fromStoredTab, + toStoredSearchEmbeddable, + toStoredSearchEmbeddableByRef, + toStoredSearchEmbeddableByValue, + toStoredGrid, + toStoredHeight, + fromDiscoverSessionPanelOverrides, + toStoredSort, + toStoredTab, +} from './transform_utils'; +import type { + SearchEmbeddableByReferenceState, + StoredSearchEmbeddableByReferenceState, + StoredSearchEmbeddableByValueState, + StoredSearchEmbeddableState, +} from './types'; +import { + DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_ID, + DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_LABEL, + SAVED_SEARCH_SAVED_OBJECT_REF_NAME, +} from './constants'; +import { SavedSearchType, VIEW_MODE } from '@kbn/saved-search-plugin/common'; +import type { + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableByValueState, +} from '../../server'; +import { DataGridDensity } from '@kbn/discover-utils'; +import { ASCODE_FILTER_OPERATOR, ASCODE_FILTER_TYPE } from '@kbn/as-code-filters-constants'; + +describe('search embeddable transform utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('fromStoredSearchEmbeddable', () => { + it('dispatches to by-reference transform when state has no attributes', () => { + const storedState: StoredSearchEmbeddableByReferenceState = { + title: 'My Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + }; + const references: SavedObjectReference[] = [ + { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-123' }, + ]; + const result = fromStoredSearchEmbeddable(storedState, references); + expect(result).toMatchObject({ + title: 'My Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + discover_session_id: 'session-123', + selected_tab_id: undefined, + overrides: {}, + }); + }); + + it('dispatches to by-value transform when state has attributes', () => { + const storedState = { + title: 'My Search', + description: 'My description', + attributes: { + title: '', + sort: [['@timestamp', 'desc']], + columns: ['message'], + grid: {}, + hideChart: false, + viewMode: VIEW_MODE.DOCUMENT_LEVEL, + isTextBasedQuery: false, + timeRestore: false, + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"query":{"language":"kuery","query":""},"index":"dv-1","filter":[]}', + }, + tabs: [ + { + id: 'tab-1', + label: 'Tab 1', + attributes: { + sort: [['@timestamp', 'desc']], + columns: ['message'], + grid: {}, + hideChart: false, + viewMode: VIEW_MODE.DOCUMENT_LEVEL, + isTextBasedQuery: false, + timeRestore: false, + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"query":{"language":"kuery","query":""},"index":"dv-1","filter":[]}', + }, + }, + }, + ], + }, + } as StoredSearchEmbeddableByValueState; + const references: SavedObjectReference[] = [ + { name: 'kibanaSavedObjectMeta.searchSourceJSON.index', type: 'index-pattern', id: 'dv-1' }, + ]; + const result = fromStoredSearchEmbeddable(storedState, references); + expect('tabs' in result && result.tabs).toBeDefined(); + expect('tabs' in result && Array.isArray(result.tabs)).toBe(true); + expect('tabs' in result && result.tabs.length).toBe(1); + }); + + it('merges attributes.references when converting by-value legacy state (embedded save and return)', () => { + const dataViewId = 'dv-from-embedded-editor'; + const indexRefName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; + const searchSourceWithRef = JSON.stringify({ + query: { language: 'kuery', query: '' }, + filter: [], + indexRefName, + }); + const storedState = { + title: 'Panel from Discover', + description: '', + attributes: { + title: '', + sort: [['@timestamp', 'desc']], + columns: ['message'], + grid: {}, + hideChart: false, + viewMode: VIEW_MODE.DOCUMENT_LEVEL, + isTextBasedQuery: false, + timeRestore: false, + kibanaSavedObjectMeta: { + searchSourceJSON: searchSourceWithRef, + }, + references: [{ name: indexRefName, type: 'index-pattern', id: dataViewId }], + tabs: [ + { + id: 'tab-1', + label: 'Tab 1', + attributes: { + sort: [['@timestamp', 'desc']], + columns: ['message'], + grid: {}, + hideChart: false, + viewMode: VIEW_MODE.DOCUMENT_LEVEL, + isTextBasedQuery: false, + timeRestore: false, + kibanaSavedObjectMeta: { + searchSourceJSON: searchSourceWithRef, + }, + }, + }, + ], + }, + } as StoredSearchEmbeddableByValueState; + + const result = fromStoredSearchEmbeddable(storedState); + + expect('tabs' in result && result.tabs).toBeDefined(); + expect('tabs' in result && result.tabs?.[0]).toMatchObject({ + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: dataViewId }, + }); + }); + }); + + describe('toStoredSearchEmbeddable', () => { + it('dispatches to by-reference transform when state has discover_session_id', () => { + const apiState: DiscoverSessionEmbeddableByReferenceState = { + title: 'My Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + discover_session_id: 'session-456', + selected_tab_id: undefined, + overrides: {}, + }; + const { state, references } = toStoredSearchEmbeddable(apiState); + expect(references).toContainEqual({ + name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, + type: SavedSearchType, + id: 'session-456', + }); + expect(state).toMatchObject({ + title: 'My Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + }); + }); + + it('dispatches to by-value transform when state has tabs', () => { + const apiState: DiscoverSessionEmbeddableByValueState = { + title: 'Panel Title', + description: 'Panel description', + tabs: [ + { + column_order: ['message'], + sort: [], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 'auto', + row_height: 'auto', + query: { language: 'kuery', query: '' }, + filters: [], + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-1' }, + }, + ], + }; + const { state, references } = toStoredSearchEmbeddable(apiState); + expect(state).toHaveProperty('attributes'); + expect((state as StoredSearchEmbeddableByValueState).attributes.tabs).toHaveLength(1); + expect(references).toContainEqual({ + id: 'data-view-1', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + }); + }); + }); + + describe('fromStoredSearchEmbeddableByValue', () => { + it('converts stored by-value SearchEmbeddable state to panel API shape', () => { + const storedState: StoredSearchEmbeddableByValueState = { + title: '[filebeat-*] elasticsearch logs', + description: 'my description', + // type: 'search', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"query":{"language":"kuery","query":"service.type: \\"elasticsearch\\""},"highlightAll":true,"fields":[{"field":"*","include_unmapped":true}],"sort":[{"@timestamp":{"order":"desc","format":"strict_date_optional_time"}},{"_doc":"desc"}],"filter":[{"meta":{"disabled":false,"negate":false,"alias":null,"key":"service.type","field":"service.type","params":{"query":"elasticsearch"},"type":"phrase","index":"c7d7a1f5-19da-4ba9-af15-5919e8cd2528"},"query":{"match_phrase":{"service.type":"elasticsearch"}},"$state":{"store":"appState"}}],"index":"c7d7a1f5-19da-4ba9-af15-5919e8cd2528"}', + }, + title: '', + sort: [['@timestamp', 'desc']], + columns: ['message'], + description: '', + grid: {}, + hideChart: false, + hideTable: false, + viewMode: VIEW_MODE.DOCUMENT_LEVEL, + isTextBasedQuery: false, + timeRestore: false, + tabs: [ + { + id: 'e0ae3a4e-67b9-4383-a8c1-ce463000b4bd', + label: 'Untitled', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"query":{"language":"kuery","query":"service.type: \\"elasticsearch\\""},"highlightAll":true,"fields":[{"field":"*","include_unmapped":true}],"sort":[{"@timestamp":{"order":"desc","format":"strict_date_optional_time"}},{"_doc":"desc"}],"filter":[{"meta":{"disabled":false,"negate":false,"alias":null,"key":"service.type","field":"service.type","params":{"query":"elasticsearch"},"type":"phrase","index":"c7d7a1f5-19da-4ba9-af15-5919e8cd2528"},"query":{"match_phrase":{"service.type":"elasticsearch"}},"$state":{"store":"appState"}}],"index":"c7d7a1f5-19da-4ba9-af15-5919e8cd2528"}', + }, + sort: [['@timestamp', 'desc']], + columns: ['message'], + grid: {}, + hideChart: false, + hideTable: false, + viewMode: VIEW_MODE.DOCUMENT_LEVEL, + isTextBasedQuery: false, + timeRestore: false, + }, + }, + ], + }, + }; + + const expected: DiscoverSessionEmbeddableByValueState = { + title: '[filebeat-*] elasticsearch logs', + description: 'my description', + tabs: [ + { + query: { language: 'kuery', query: 'service.type: "elasticsearch"' }, + filters: [ + { + type: ASCODE_FILTER_TYPE.CONDITION, + condition: { + field: 'service.type', + operator: ASCODE_FILTER_OPERATOR.IS, + value: 'elasticsearch', + }, + data_view_id: 'c7d7a1f5-19da-4ba9-af15-5919e8cd2528', + disabled: false, + negate: false, + }, + ], + sort: [{ name: '@timestamp', direction: 'desc' }], + column_order: ['message'], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 3, + data_source: { + type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, + id: 'c7d7a1f5-19da-4ba9-af15-5919e8cd2528', + }, + }, + ], + }; + + const result = fromStoredSearchEmbeddableByValue(storedState); + + expect(result).toEqual(expected); + }); + }); + + describe('fromStoredSearchEmbeddableByRef', () => { + it('converts stored by-reference SearchEmbeddable state to panel API shape', () => { + const storedSearch: StoredSearchEmbeddableByReferenceState = { + title: 'My Saved Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + }; + const references: SavedObjectReference[] = [ + { name: 'savedObjectRef', type: SavedSearchType, id: 'session-123' }, + ]; + const result = fromStoredSearchEmbeddableByRef(storedSearch, references); + expect(result).toEqual({ + title: 'My Saved Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + discover_session_id: 'session-123', + selected_tab_id: undefined, + overrides: {}, + }); + }); + + it('puts editable panel fields in overrides (not top-level) and maps selectedTabId to selected_tab_id', () => { + const storedSearch: StoredSearchEmbeddableByReferenceState = { + title: 'My Saved Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + selectedTabId: 'tab-active', + sort: [['@timestamp', 'desc']], + columns: ['message'], + rowHeight: -1, + sampleSize: 500, + rowsPerPage: 100, + headerRowHeight: 3, + density: DataGridDensity.COMPACT, + grid: { + columns: { + message: { width: 100 }, + }, + }, + }; + const references: SavedObjectReference[] = [ + { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-xyz' }, + ]; + const result = fromStoredSearchEmbeddableByRef(storedSearch, references); + expect(result).toEqual({ + title: 'My Saved Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + discover_session_id: 'session-xyz', + selected_tab_id: 'tab-active', + overrides: { + sort: [{ name: '@timestamp', direction: 'desc' }], + column_order: ['message'], + column_settings: { message: { width: 100 } }, + row_height: 'auto', + sample_size: 500, + rows_per_page: 100, + header_row_height: 3, + density: DataGridDensity.COMPACT, + }, + }); + expect(result).not.toHaveProperty('sort'); + expect(result).not.toHaveProperty('columns'); + expect(result).not.toHaveProperty('selectedTabId'); + }); + + it('throws when no saved search reference matches type and name', () => { + const storedSearch: StoredSearchEmbeddableByReferenceState = { + title: 'My Saved Search', + }; + expect(() => fromStoredSearchEmbeddableByRef(storedSearch, [])).toThrow( + `Missing reference of type "${SavedSearchType}"` + ); + expect(() => + fromStoredSearchEmbeddableByRef(storedSearch, [ + { name: 'wrongRefName', type: SavedSearchType, id: 'id-1' }, + ]) + ).toThrow(`Missing reference of type "${SavedSearchType}"`); + }); + + it('uses the reference that matches SavedSearchType and SAVED_SEARCH_SAVED_OBJECT_REF_NAME', () => { + const storedSearch: StoredSearchEmbeddableByReferenceState = { + title: 'Panel', + }; + const references: SavedObjectReference[] = [ + { name: 'kibanaSavedObjectMeta.searchSourceJSON.index', type: 'index-pattern', id: 'dv-1' }, + { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-picked' }, + ]; + const result = fromStoredSearchEmbeddableByRef(storedSearch, references); + expect(result.discover_session_id).toBe('session-picked'); + }); + + it('uses savedObjectId on state when present so a saved search reference is not required', () => { + const storedSearch: SearchEmbeddableByReferenceState = { + title: 'Runtime / API state', + savedObjectId: 'session-without-ref-array', + }; + const result = fromStoredSearchEmbeddableByRef(storedSearch, []); + expect(result.discover_session_id).toBe('session-without-ref-array'); + }); + + it('prefers savedObjectId on state over the matching saved search reference', () => { + const storedSearch: SearchEmbeddableByReferenceState = { + title: 'Panel', + savedObjectId: 'id-from-state', + }; + const references: SavedObjectReference[] = [ + { + name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, + type: SavedSearchType, + id: 'id-from-reference', + }, + ]; + const result = fromStoredSearchEmbeddableByRef(storedSearch, references); + expect(result.discover_session_id).toBe('id-from-state'); + }); + }); + + describe('toStoredSearchEmbeddableByRef', () => { + it('converts panel API by-reference state to stored SearchEmbeddable state with references', () => { + const apiState: DiscoverSessionEmbeddableByReferenceState = { + title: 'My Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + discover_session_id: 'session-456', + selected_tab_id: 'tab-1', + overrides: {}, + }; + const result = toStoredSearchEmbeddableByRef(apiState); + expect(result.references).toEqual([ + { + name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, + type: SavedSearchType, + id: 'session-456', + }, + ]); + expect(result.state).toEqual({ + title: 'My Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + selectedTabId: 'tab-1', + }); + }); + }); + + describe('toStoredSearchEmbeddableByValue', () => { + it('converts panel API by-value state to stored SearchEmbeddable state with references', () => { + const apiState: DiscoverSessionEmbeddableByValueState = { + title: 'Panel Title', + description: 'Panel description', + time_range: { from: 'now-1h', to: 'now' }, + tabs: [ + { + column_order: ['message', '@timestamp'], + column_settings: { '@timestamp': { width: 200 } }, + sort: [{ name: '@timestamp', direction: 'desc' }], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 'auto', + row_height: 'auto', + query: { language: 'kuery', query: '' }, + filters: [], + rows_per_page: 100, + sample_size: 1000, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-1' }, + }, + ], + }; + const result = toStoredSearchEmbeddableByValue(apiState); + expect(result.references).toEqual([ + { + id: 'data-view-1', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + }, + ]); + expect(result.state.title).toBe('Panel Title'); + expect(result.state.description).toBe('Panel description'); + expect(result.state.attributes.tabs).toHaveLength(1); + expect(result.state.attributes.tabs[0].id).toBe(DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_ID); + expect(result.state.attributes.tabs[0].label).toBe( + DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_LABEL + ); + expect(result.state.attributes.tabs[0].attributes.columns).toEqual(['message', '@timestamp']); + expect(result.state.attributes.tabs[0].attributes.sort).toEqual([['@timestamp', 'desc']]); + expect(result.state.attributes.tabs[0].attributes.grid).toEqual({ + columns: { '@timestamp': { width: 200 } }, + }); + expect(result.state.attributes.tabs[0].attributes.rowHeight).toBe(-1); + expect(result.state.attributes.tabs[0].attributes.headerRowHeight).toBe(-1); + const searchSource = JSON.parse( + result.state.attributes.tabs[0].attributes.kibanaSavedObjectMeta.searchSourceJSON + ); + expect(searchSource.indexRefName).toBe('kibanaSavedObjectMeta.searchSourceJSON.index'); + expect(searchSource.index).toBeUndefined(); + expect(searchSource.query).toEqual({ language: 'kuery', query: '' }); + expect(searchSource.filter).toEqual([]); + }); + + it('converts index-pattern tab with runtime fields to stored state', () => { + const apiState: DiscoverSessionEmbeddableByValueState = { + title: 'Adhoc', + time_range: { from: 'now-1h', to: 'now' }, + tabs: [ + { + column_order: ['foo'], + sort: [], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 50, + row_height: 30, + query: { language: 'kuery', query: '' }, + filters: [], + rows_per_page: 25, + sample_size: 500, + data_source: { + type: AS_CODE_DATA_VIEW_SPEC_TYPE, + index_pattern: 'my-*', + time_field: '@timestamp', + runtime_fields: [ + { + name: 'rt', + type: 'keyword', + script: 'emit("x")', + format: { type: 'string' }, + }, + ], + }, + }, + ], + }; + const result = toStoredSearchEmbeddableByValue(apiState); + const searchSource = JSON.parse( + result.state.attributes.tabs[0].attributes.kibanaSavedObjectMeta.searchSourceJSON + ); + expect(searchSource.index).toEqual({ + title: 'my-*', + timeFieldName: '@timestamp', + fieldFormats: { + rt: { id: 'string' }, + }, + fieldAttrs: { + rt: {}, + }, + runtimeFieldMap: { + rt: { + type: 'keyword', + script: { source: 'emit("x")' }, + }, + }, + }); + }); + }); + + describe('transform then reversion (1:1 validation)', () => { + it('by-value: SavedSearch → API → SavedSearch yields semantically identical state', () => { + const storedState: StoredSearchEmbeddableByValueState = { + title: 'My Discover Session', + description: 'Session description', + attributes: { + title: '', + description: '', + sort: [['@timestamp', 'desc']], + columns: ['message', '@timestamp'], + grid: { columns: { '@timestamp': { width: 200 } } }, + hideChart: false, + hideTable: false, + viewMode: VIEW_MODE.DOCUMENT_LEVEL, + isTextBasedQuery: false, + timeRestore: false, + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"query":{"language":"kuery","query":""},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', + }, + tabs: [ + { + id: 'tab-1', + label: 'Tab 1', + attributes: { + sort: [['@timestamp', 'desc']], + columns: ['message', '@timestamp'], + grid: { columns: { '@timestamp': { width: 200 } } }, + hideChart: false, + hideTable: false, + viewMode: VIEW_MODE.DOCUMENT_LEVEL, + isTextBasedQuery: false, + timeRestore: false, + rowHeight: -1, + headerRowHeight: -1, + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"query":{"language":"kuery","query":""},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', + }, + }, + }, + ], + }, + }; + const references: SavedObjectReference[] = [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: 'data-view-123', + }, + ]; + + const apiState = fromStoredSearchEmbeddableByValue(storedState, references); + const { state: reverted, references: revertedRefs } = toStoredSearchEmbeddableByValue( + apiState, + [] + ); + + expect(reverted.attributes.title).toBe(storedState.title); + expect(reverted.attributes.description).toBe(storedState.description); + expect(reverted.attributes.tabs).toHaveLength(storedState.attributes.tabs!.length); + expect(reverted.attributes.tabs[0].id).toBe(DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_ID); + expect(reverted.attributes.tabs[0].label).toBe( + DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_LABEL + ); + + const initialTabAttrs = storedState.attributes.tabs![0].attributes; + const revertedTabAttrs = reverted.attributes.tabs[0].attributes; + expect(revertedTabAttrs.sort).toEqual(initialTabAttrs.sort); + expect(revertedTabAttrs.columns).toEqual(initialTabAttrs.columns); + expect(revertedTabAttrs.grid).toEqual(initialTabAttrs.grid); + expect(revertedTabAttrs.hideChart).toBe(initialTabAttrs.hideChart); + expect(revertedTabAttrs.viewMode).toBe(initialTabAttrs.viewMode); + expect(revertedTabAttrs.isTextBasedQuery).toBe(initialTabAttrs.isTextBasedQuery); + // timeRestore/timeRange are intentionally dropped at the simplified API level + expect(revertedTabAttrs.rowHeight).toBe(initialTabAttrs.rowHeight); + expect(revertedTabAttrs.headerRowHeight).toBe(initialTabAttrs.headerRowHeight); + expect(revertedTabAttrs.kibanaSavedObjectMeta.searchSourceJSON).toBe( + initialTabAttrs.kibanaSavedObjectMeta.searchSourceJSON + ); + + expect(revertedRefs).toEqual(references); + }); + + it('by-reference: SavedSearch → API → SavedSearch yields semantically identical state', () => { + const storedState: StoredSearchEmbeddableByReferenceState = { + title: 'By-Ref Session', + description: 'Ref description', + time_range: { from: 'now-15m', to: 'now' }, + selectedTabId: 'tab-2', + sort: [['_score', 'desc']], + columns: ['message'], + }; + const references: SavedObjectReference[] = [ + { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-ref-1' }, + ]; + + const apiState = fromStoredSearchEmbeddableByRef(storedState, references); + const { state: reverted, references: revertedRefs } = toStoredSearchEmbeddableByRef( + apiState, + [] + ); + + expect(reverted.title).toBe(storedState.title); + expect(reverted.description).toBe(storedState.description); + expect(reverted.time_range).toEqual(storedState.time_range); + expect(reverted.selectedTabId).toBe(storedState.selectedTabId); + expect(reverted.sort).toEqual(storedState.sort); + expect(reverted.columns).toEqual(storedState.columns); + expect(revertedRefs).toEqual(references); + }); + }); + + describe('fromStoredGrid', () => { + it('maps saved grid.columns to column_settings', () => { + expect( + fromStoredGrid({ + columns: { + message: { width: 100 }, + '@timestamp': { width: 200 }, + }, + }) + ).toEqual({ + message: { width: 100 }, + '@timestamp': { width: 200 }, + }); + }); + + it('returns empty object when grid has no column entries', () => { + expect(fromStoredGrid({ columns: {} })).toEqual({}); + expect(fromStoredGrid({})).toEqual({}); + }); + }); + + describe('toStoredGrid', () => { + it('builds saved grid from non-empty column_settings', () => { + expect( + toStoredGrid({ + message: { width: 100 }, + '@timestamp': { width: 200 }, + }) + ).toEqual({ + columns: { + message: { width: 100 }, + '@timestamp': { width: 200 }, + }, + }); + }); + + it('returns empty object when column_settings is empty', () => { + expect(toStoredGrid({})).toEqual({}); + }); + + it('returns empty object when column_settings is undefined (default)', () => { + expect(toStoredGrid()).toEqual({}); + }); + }); + + describe('fromStoredPanelOverrides', () => { + it('converts stored state with all fields to panel overrides', () => { + const storedState: StoredSearchEmbeddableState = { + sort: [['@timestamp', 'desc']], + columns: ['message', '@timestamp'], + rowHeight: -1, + sampleSize: 500, + rowsPerPage: 100, + headerRowHeight: 3, + density: DataGridDensity.COMPACT, + grid: { + columns: { + message: { width: 100 }, + '@timestamp': { width: 200 }, + }, + }, + }; + const result = toDiscoverSessionPanelOverrides(storedState); + expect(result).toEqual({ + sort: [{ name: '@timestamp', direction: 'desc' }], + column_order: ['message', '@timestamp'], + column_settings: { + message: { width: 100 }, + '@timestamp': { width: 200 }, + }, + row_height: 'auto', + sample_size: 500, + rows_per_page: 100, + header_row_height: 3, + density: DataGridDensity.COMPACT, + }); + }); + + it('omits undefined/falsy stored fields from result', () => { + const storedState: StoredSearchEmbeddableState = { + sort: [['@timestamp', 'desc']], + columns: ['message'], + grid: { columns: {} }, + }; + const result = toDiscoverSessionPanelOverrides(storedState); + expect(result).toEqual({ + sort: [{ name: '@timestamp', direction: 'desc' }], + column_order: ['message'], + }); + expect(result.row_height).toBeUndefined(); + expect(result.sample_size).toBeUndefined(); + expect(result.rows_per_page).toBeUndefined(); + expect(result.header_row_height).toBeUndefined(); + expect(result.density).toBeUndefined(); + }); + + it('converts numeric row heights to API form', () => { + const storedState = { + rowHeight: 5, + headerRowHeight: 2, + }; + const result = toDiscoverSessionPanelOverrides(storedState); + expect(result.row_height).toBe(5); + expect(result.header_row_height).toBe(2); + }); + + it('converts -1 height to "auto"', () => { + const storedState = { + rowHeight: -1, + headerRowHeight: -1, + }; + const result = toDiscoverSessionPanelOverrides(storedState); + expect(result.row_height).toBe('auto'); + expect(result.header_row_height).toBe('auto'); + }); + }); + + describe('toStoredPanelOverrides', () => { + it('converts panel overrides with all fields to stored state', () => { + const apiState = { + sort: [{ name: '@timestamp', direction: 'desc' as const }], + column_order: ['message', '@timestamp'], + column_settings: { '@timestamp': { width: 200 } }, + row_height: 'auto' as const, + sample_size: 500, + rows_per_page: 100 as const, + header_row_height: 3, + density: DataGridDensity.COMPACT, + }; + const result = fromDiscoverSessionPanelOverrides(apiState); + expect(result).toEqual({ + sort: [['@timestamp', 'desc']], + columns: ['message', '@timestamp'], + rowHeight: -1, + sampleSize: 500, + rowsPerPage: 100, + headerRowHeight: 3, + density: DataGridDensity.COMPACT, + grid: { + columns: { + '@timestamp': { width: 200 }, + }, + }, + }); + }); + + it('omits undefined/falsy API fields from result', () => { + const apiState = { + sort: [{ name: '@timestamp', direction: 'desc' as const }], + column_order: ['message'], + }; + const result = fromDiscoverSessionPanelOverrides(apiState); + expect(result).toEqual({ + sort: [['@timestamp', 'desc']], + columns: ['message'], + }); + expect(result.rowHeight).toBeUndefined(); + expect(result.sampleSize).toBeUndefined(); + expect(result.rowsPerPage).toBeUndefined(); + expect(result.headerRowHeight).toBeUndefined(); + expect(result.density).toBeUndefined(); + }); + + it('converts "auto" height to -1 in stored form', () => { + const apiState = { + row_height: 'auto' as const, + header_row_height: 'auto' as const, + }; + const result = fromDiscoverSessionPanelOverrides(apiState); + expect(result.rowHeight).toBe(-1); + expect(result.headerRowHeight).toBe(-1); + }); + + it('preserves numeric heights in stored form', () => { + const apiState = { + row_height: 5, + header_row_height: 2, + }; + const result = fromDiscoverSessionPanelOverrides(apiState); + expect(result.rowHeight).toBe(5); + expect(result.headerRowHeight).toBe(2); + }); + + it('round-trips with fromStoredPanelOverrides', () => { + const storedState: StoredSearchEmbeddableState = { + sort: [ + ['@timestamp', 'desc'], + ['message', 'asc'], + ], + columns: ['message', '@timestamp'], + rowHeight: -1, + sampleSize: 1000, + rowsPerPage: 50, + headerRowHeight: 3, + density: DataGridDensity.NORMAL, + grid: { + columns: { + '@timestamp': { width: 150 }, + }, + }, + }; + const overrides = toDiscoverSessionPanelOverrides(storedState); + const back = fromDiscoverSessionPanelOverrides(overrides); + expect(back.sort).toEqual(storedState.sort); + expect(back.columns).toEqual(storedState.columns); + expect(back.rowHeight).toBe(storedState.rowHeight); + expect(back.sampleSize).toBe(storedState.sampleSize); + expect(back.rowsPerPage).toBe(storedState.rowsPerPage); + expect(back.headerRowHeight).toBe(storedState.headerRowHeight); + expect(back.density).toBe(storedState.density); + expect(back.grid).toEqual(storedState.grid); + }); + }); + + describe('fromStoredSort', () => { + it('converts array of [field, direction] to sort objects', () => { + const sort = [ + ['@timestamp', 'desc'], + ['message', 'asc'], + ]; + const result = fromStoredSort(sort); + expect(result).toEqual([ + { name: '@timestamp', direction: 'desc' }, + { name: 'message', direction: 'asc' }, + ]); + }); + + it('defaults direction to desc when not asc or desc', () => { + const sort = [['field', 'other' as 'desc']]; + const result = fromStoredSort(sort); + expect(result).toEqual([{ name: 'field', direction: 'desc' }]); + }); + }); + + describe('toStoredSort', () => { + it('converts sort objects to array of [name, direction]', () => { + const sort = [ + { name: '@timestamp', direction: 'desc' as const }, + { name: 'message', direction: 'asc' as const }, + ]; + const result = toStoredSort(sort); + expect(result).toEqual([ + ['@timestamp', 'desc'], + ['message', 'asc'], + ]); + }); + + it('returns empty array when sort is undefined (default)', () => { + expect(toStoredSort()).toEqual([]); + }); + + it('returns empty array when sort is empty', () => { + expect(toStoredSort([])).toEqual([]); + }); + }); + + describe('fromStoredHeight', () => { + it('returns numeric height as-is', () => { + expect(fromStoredHeight(3)).toBe(3); + expect(fromStoredHeight(5)).toBe(5); + }); + + it('returns "auto" when height is -1', () => { + expect(fromStoredHeight(-1)).toBe('auto'); + }); + + it('defaults to 3 when height is undefined', () => { + expect(fromStoredHeight(undefined as unknown as number)).toBe(3); + }); + }); + + describe('toStoredHeight', () => { + it('returns numeric height as-is', () => { + expect(toStoredHeight(3)).toBe(3); + expect(toStoredHeight(5)).toBe(5); + }); + + it('returns -1 when height is "auto"', () => { + expect(toStoredHeight('auto')).toBe(-1); + }); + }); + + describe('fromStoredTab', () => { + it('converts stored tab with dataView id to API tab', () => { + const storedTab = { + sort: [['@timestamp', 'desc']], + columns: ['message', '@timestamp'], + grid: { columns: { '@timestamp': { width: 200 } } }, + rowHeight: -1, + headerRowHeight: -1, + sampleSize: 500, + rowsPerPage: 100, + density: DataGridDensity.COMPACT, + viewMode: VIEW_MODE.DOCUMENT_LEVEL, + hideChart: false, + isTextBasedQuery: false, + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + query: { language: 'kuery', query: '' }, + index: 'data-view-1', + filter: [], + }), + }, + }; + const references: SavedObjectReference[] = [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: 'data-view-1', + }, + ]; + const result = fromStoredTab( + storedTab as unknown as Parameters[0], + references + ); + expect(result.sort).toEqual([{ name: '@timestamp', direction: 'desc' }]); + expect(result.column_order).toEqual(['message', '@timestamp']); + expect(result.column_settings).toEqual({ '@timestamp': { width: 200 } }); + expect(result.row_height).toBe('auto'); + expect(result.header_row_height).toBe('auto'); + expect(result.density).toBe(DataGridDensity.COMPACT); + expect('data_source' in result && result.data_source).toEqual({ + type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, + id: 'data-view-1', + }); + expect('view_mode' in result && result.view_mode).toBe(VIEW_MODE.DOCUMENT_LEVEL); + }); + }); + + describe('toStoredTab', () => { + it('converts API classic tab to stored tab with references', () => { + const apiTab: DiscoverSessionEmbeddableByValueState['tabs'][0] = { + column_order: ['message', '@timestamp'], + column_settings: { '@timestamp': { width: 200 } }, + sort: [{ name: '@timestamp', direction: 'desc' }], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 'auto', + row_height: 'auto', + query: { language: 'kuery', query: '' }, + filters: [], + rows_per_page: 100, + sample_size: 500, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-1' }, + }; + const { state, references } = toStoredTab(apiTab); + expect(references).toContainEqual({ + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: 'data-view-1', + }); + expect(state.sort).toEqual([['@timestamp', 'desc']]); + expect(state.columns).toEqual(['message', '@timestamp']); + expect(state.rowHeight).toBe(-1); + expect(state.headerRowHeight).toBe(-1); + expect(state.density).toBe(DataGridDensity.COMPACT); + expect(state.hideChart).toBe(false); + expect(state.isTextBasedQuery).toBe(false); + const searchSource = JSON.parse(state.kibanaSavedObjectMeta.searchSourceJSON); + expect(searchSource.indexRefName).toBe('kibanaSavedObjectMeta.searchSourceJSON.index'); + expect(searchSource.index).toBeUndefined(); + expect(searchSource.query).toEqual({ language: 'kuery', query: '' }); + expect(searchSource.filter).toEqual([]); + }); + + it('converts API tab with index-pattern data_source (no refs) when inline', () => { + const apiTab: DiscoverSessionEmbeddableByValueState['tabs'][0] = { + column_order: ['foo'], + sort: [], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 3, + row_height: 3, + query: { language: 'kuery', query: '' }, + filters: [], + data_source: { + type: AS_CODE_DATA_VIEW_SPEC_TYPE, + index_pattern: 'my-*', + time_field: '@timestamp', + }, + }; + const { state, references } = toStoredTab(apiTab); + expect(references).toEqual([]); + const searchSource = JSON.parse(state.kibanaSavedObjectMeta.searchSourceJSON); + expect(searchSource.index).toEqual({ + title: 'my-*', + timeFieldName: '@timestamp', + }); + }); + }); +}); diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts new file mode 100644 index 0000000000000..5cc77cd4c0475 --- /dev/null +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -0,0 +1,316 @@ +/* + * 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 { DiscoverSessionTabAttributes } from '@kbn/saved-search-plugin/server'; +import type { SavedSearchAttributes } from '@kbn/saved-search-plugin/common'; +import { extractTabs, SavedSearchType, VIEW_MODE } from '@kbn/saved-search-plugin/common'; +import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { + extractReferences, + injectReferences, + parseSearchSourceJSON, +} from '@kbn/data-plugin/common'; +import { fromStoredFilters, toStoredFilters } from '@kbn/as-code-filters-transforms'; +import { fromStoredDataView, toStoredDataView } from '@kbn/as-code-data-views-transforms'; +import type { SavedObjectReference } from '@kbn/core/server'; +import { DataGridDensity } from '@kbn/discover-utils'; +import { isOfAggregateQueryType } from '@kbn/es-query'; +import { + isDiscoverSessionEmbeddableByReferenceState, + isSearchEmbeddableByValueState, +} from './type_guards'; +import type { + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableByValueState, + DiscoverSessionEmbeddableState, + DiscoverSessionPanelOverrides, + DiscoverSessionTab, +} from '../../server'; +import type { + SearchEmbeddableByReferenceState, + SearchEmbeddableState, + StoredSearchEmbeddableByReferenceState, + StoredSearchEmbeddableByValueState, + StoredSearchEmbeddableState, +} from './types'; +import { + DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_ID, + DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_LABEL, + SAVED_SEARCH_SAVED_OBJECT_REF_NAME, +} from './constants'; + +export function fromStoredSearchEmbeddable( + storedState: SearchEmbeddableState | StoredSearchEmbeddableState, + references: SavedObjectReference[] = [] +): DiscoverSessionEmbeddableState { + return isSearchEmbeddableByValueState(storedState) + ? fromStoredSearchEmbeddableByValue(storedState, [ + ...references, + ...(storedState.attributes.references ?? []), + ]) + : fromStoredSearchEmbeddableByRef(storedState, references); +} + +export function toStoredSearchEmbeddable( + apiState: DiscoverSessionEmbeddableState, + references: SavedObjectReference[] = [] +): { state: StoredSearchEmbeddableState; references: SavedObjectReference[] } { + return isDiscoverSessionEmbeddableByReferenceState(apiState) + ? toStoredSearchEmbeddableByRef(apiState, references) + : toStoredSearchEmbeddableByValue(apiState, references); +} + +export function fromStoredSearchEmbeddableByRef( + storedState: SearchEmbeddableByReferenceState | StoredSearchEmbeddableByReferenceState, + references: SavedObjectReference[] = [] +): DiscoverSessionEmbeddableByReferenceState { + const { + sort, + columns, + rowHeight, + sampleSize, + rowsPerPage, + headerRowHeight, + density, + grid, + savedObjectId, + selectedTabId, + ...otherAttrs + } = { + savedObjectId: references.find( + (ref) => SavedSearchType === ref.type && ref.name === SAVED_SEARCH_SAVED_OBJECT_REF_NAME + )?.id, + ...storedState, + }; + if (!savedObjectId) throw new Error(`Missing reference of type "${SavedSearchType}"`); + return { + ...otherAttrs, + discover_session_id: savedObjectId, + selected_tab_id: selectedTabId, + overrides: toDiscoverSessionPanelOverrides(storedState), + }; +} + +export function toStoredSearchEmbeddableByRef( + apiState: DiscoverSessionEmbeddableByReferenceState, + references: SavedObjectReference[] = [] +): { state: StoredSearchEmbeddableByReferenceState; references: SavedObjectReference[] } { + const discoverSessionReference: SavedObjectReference = { + name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, + type: SavedSearchType, + id: apiState.discover_session_id, + }; + const { discover_session_id, selected_tab_id, overrides, ...otherAttrs } = apiState; + const state: StoredSearchEmbeddableByReferenceState = { + ...otherAttrs, + ...fromDiscoverSessionPanelOverrides(overrides ?? {}), + ...(selected_tab_id != null && { selectedTabId: selected_tab_id }), + }; + return { + state, + references: [...references, discoverSessionReference], + }; +} + +export function fromStoredSearchEmbeddableByValue( + storedState: StoredSearchEmbeddableByValueState, + references: SavedObjectReference[] = [] +): DiscoverSessionEmbeddableByValueState { + const { + sort, + columns, + rowHeight, + sampleSize, + rowsPerPage, + headerRowHeight, + density, + grid, + attributes, + ...otherAttrs + } = storedState; + const [tab] = attributes.tabs ?? extractTabs(attributes).tabs; + const apiTab = fromStoredTab(tab.attributes, references); + const panelOverrides = toDiscoverSessionPanelOverrides(storedState); + return { + ...otherAttrs, + tabs: [{ ...apiTab, ...panelOverrides }], + }; +} + +export function toStoredSearchEmbeddableByValue( + apiState: DiscoverSessionEmbeddableByValueState, + references: SavedObjectReference[] = [] +): { state: StoredSearchEmbeddableByValueState; references: SavedObjectReference[] } { + const { + tabs: [apiTab], + ...otherAttrs + } = apiState; + const { state: tabAttributes, references: tabReferences } = toStoredTab(apiTab); + const state: StoredSearchEmbeddableByValueState = { + ...otherAttrs, + ...fromDiscoverSessionPanelOverrides(apiTab), + attributes: { + ...tabAttributes, + sort: tabAttributes.sort as SavedSearchAttributes['sort'], + title: apiState.title ?? '', + description: apiState.description ?? '', + tabs: [ + { + id: DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_ID, + label: DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_LABEL, + attributes: tabAttributes, + }, + ], + }, + }; + return { + state, + references: [...references, ...tabReferences], + }; +} + +export function fromStoredTab( + tab: DiscoverSessionTabAttributes, + references: SavedObjectReference[] = [] +): DiscoverSessionTab { + const { + sort, + sampleSize, + rowsPerPage, + headerRowHeight, + density, + viewMode, + kibanaSavedObjectMeta: { searchSourceJSON }, + } = tab; + const apiTab = { + ...toDiscoverSessionPanelOverrides(tab), + sort: fromStoredSort(sort), + header_row_height: fromStoredHeight(headerRowHeight), + density: density ?? DataGridDensity.COMPACT, + }; + const searchSourceValues = parseSearchSourceJSON(searchSourceJSON); + const { index, query, filter } = injectReferences(searchSourceValues, references); + return isOfAggregateQueryType(query) + ? { ...apiTab, query } + : { + ...apiTab, + ...(sampleSize && { sample_size: sampleSize }), + ...(rowsPerPage && { rows_per_page: rowsPerPage }), + query, + filters: fromStoredFilters(filter) ?? [], + data_source: fromStoredDataView(index), + view_mode: viewMode ?? VIEW_MODE.DOCUMENT_LEVEL, + }; +} + +export function toStoredTab(apiTab: DiscoverSessionTab): { + state: DiscoverSessionTabAttributes; + references: SavedObjectReference[]; +} { + const { sort, column_order: columnOrder, column_settings: columnSettings } = apiTab; + const searchSourceValues: SerializedSearchSourceFields = { + query: apiTab.query, + ...('filters' in apiTab && { filter: toStoredFilters(apiTab.filters) }), + ...('data_source' in apiTab && { index: toStoredDataView(apiTab.data_source) }), + }; + const [searchSourceFields, references] = extractReferences(searchSourceValues); + const state: DiscoverSessionTabAttributes = { + ...fromDiscoverSessionPanelOverrides(apiTab), + sort: toStoredSort(sort), + columns: columnOrder ?? [], + grid: toStoredGrid(columnSettings), + hideChart: false, + hideTable: false, + isTextBasedQuery: isOfAggregateQueryType(apiTab.query), + kibanaSavedObjectMeta: { searchSourceJSON: JSON.stringify(searchSourceFields) }, + ...('view_mode' in apiTab && { viewMode: apiTab.view_mode }), + }; + return { state, references }; +} + +export function toDiscoverSessionPanelOverrides( + storedState: StoredSearchEmbeddableState | DiscoverSessionTabAttributes +): DiscoverSessionPanelOverrides { + const { sort, columns, rowHeight, sampleSize, rowsPerPage, headerRowHeight, density, grid } = + storedState; + return { + ...(sort && { sort: fromStoredSort(sort) }), + ...(columns && { column_order: columns }), + ...(grid && + Object.keys(grid?.columns ?? {}).length && { column_settings: fromStoredGrid(grid) }), + ...(rowHeight && { row_height: fromStoredHeight(rowHeight) }), + ...(sampleSize && { sample_size: sampleSize }), + ...(rowsPerPage && { rows_per_page: rowsPerPage }), + ...(headerRowHeight && { header_row_height: fromStoredHeight(headerRowHeight) }), + ...(density && { density }), + }; +} + +export function fromDiscoverSessionPanelOverrides( + apiState: DiscoverSessionPanelOverrides +): StoredSearchEmbeddableState { + const { + sort, + column_order: columnOrder, + column_settings: columnSettings, + row_height: rowHeight, + sample_size: sampleSize, + rows_per_page: rowsPerPage, + header_row_height: headerRowHeight, + density, + } = apiState; + return { + ...(sort && { sort: toStoredSort(sort) }), + ...(columnOrder && { columns: columnOrder }), + ...(rowHeight && { rowHeight: toStoredHeight(rowHeight) }), + ...(sampleSize && { sampleSize }), + ...(rowsPerPage && { rowsPerPage }), + ...(headerRowHeight && { headerRowHeight: toStoredHeight(headerRowHeight) }), + ...(density && { density }), + ...(Object.keys(columnSettings ?? {}).length && { grid: toStoredGrid(columnSettings) }), + }; +} + +export function fromStoredGrid( + grid: DiscoverSessionTabAttributes['grid'] +): DiscoverSessionTab['column_settings'] { + return grid.columns ?? {}; +} + +export function toStoredGrid( + columnSettings: DiscoverSessionTab['column_settings'] = {} +): DiscoverSessionTabAttributes['grid'] { + return Object.keys(columnSettings).length > 0 ? { columns: columnSettings } : {}; +} + +export function fromStoredSort( + sort: DiscoverSessionTabAttributes['sort'] +): DiscoverSessionTab['sort'] { + return sort.map((s) => { + const [name, dir] = Array.isArray(s) ? s : [s, 'desc']; + const direction = dir === 'asc' || dir === 'desc' ? dir : 'desc'; + return { name, direction }; + }); +} + +export function toStoredSort( + sort: DiscoverSessionTab['sort'] = [] +): DiscoverSessionTabAttributes['sort'] & SavedSearchAttributes['sort'] { + return sort.map((s) => [s.name, s.direction]); +} + +export function fromStoredHeight(height: number = 3): DiscoverSessionTab['row_height'] { + return height === -1 ? 'auto' : height; +} + +export function toStoredHeight( + height: DiscoverSessionTab['row_height'] | DiscoverSessionTab['header_row_height'] +): number { + return typeof height === 'number' ? height : -1; // -1 === 'auto' +} diff --git a/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts b/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts new file mode 100644 index 0000000000000..f3cf99ed1ad32 --- /dev/null +++ b/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableState, +} from '../../server'; +import type { + SearchEmbeddableByValueState, + SearchEmbeddablePanelApiState, + SearchEmbeddableState, + StoredSearchEmbeddableByValueState, + StoredSearchEmbeddableState, +} from './types'; + +export function isDiscoverSessionEmbeddableByReferenceState( + state: DiscoverSessionEmbeddableState +): state is DiscoverSessionEmbeddableByReferenceState { + return 'discover_session_id' in state; +} + +export function isSearchEmbeddableByValueState( + state: SearchEmbeddableState | StoredSearchEmbeddableState +): state is SearchEmbeddableByValueState | StoredSearchEmbeddableByValueState { + return 'attributes' in state && typeof state.attributes === 'object' && state.attributes !== null; +} + +export function isSearchEmbeddableLegacyPanelState( + state: SearchEmbeddablePanelApiState +): state is SearchEmbeddableState { + return 'savedObjectId' in state || isSearchEmbeddableByValueState(state); +} diff --git a/src/platform/plugins/shared/discover/common/embeddable/types.ts b/src/platform/plugins/shared/discover/common/embeddable/types.ts index d06101f74a815..9154d333a83ec 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/types.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/types.ts @@ -8,11 +8,12 @@ */ import type { SerializedTimeRange, SerializedTitles } from '@kbn/presentation-publishing'; +import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; import type { SavedSearchAttributes, SavedSearchByValueAttributes, } from '@kbn/saved-search-plugin/common'; -import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; +import type { DiscoverSessionEmbeddableState } from '../../server'; import type { EDITABLE_SAVED_SEARCH_KEYS } from './constants'; // These are options that are not persisted in the saved object, but can be used by solutions @@ -45,6 +46,8 @@ export type SearchEmbeddableByReferenceState = SearchEmbeddableBaseState & { export type SearchEmbeddableState = SearchEmbeddableByValueState | SearchEmbeddableByReferenceState; +export type SearchEmbeddablePanelApiState = DiscoverSessionEmbeddableState | SearchEmbeddableState; + export type StoredSearchEmbeddableByValueState = SearchEmbeddableByValueState; export type StoredSearchEmbeddableByReferenceState = Omit< diff --git a/src/platform/plugins/shared/discover/common/index.ts b/src/platform/plugins/shared/discover/common/index.ts index f712244098e44..43fe31a8499f9 100644 --- a/src/platform/plugins/shared/discover/common/index.ts +++ b/src/platform/plugins/shared/discover/common/index.ts @@ -10,7 +10,7 @@ export const PLUGIN_ID = 'discover'; export const APP_ICON = 'discoverApp'; -export { APP_STATE_URL_KEY } from './constants'; +export { APP_STATE_URL_KEY, EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY } from './constants'; export { DISCOVER_APP_LOCATOR } from './app_locator'; export type { DiscoverAppLocator, diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index 84e2f56a64688..6402e381351b7 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -129,13 +129,17 @@ dependsOn: - '@kbn/presentation-publishing-schemas' - '@kbn/esql' - '@kbn/controls-schemas' + - '@kbn/as-code-data-views-schema' + - '@kbn/as-code-data-views-transforms' - '@kbn/as-code-filters-schema' + - '@kbn/as-code-filters-transforms' - '@kbn/scout' - '@kbn/scout-synthtrace' - '@kbn/synthtrace-client' - '@kbn/alerting-v2-plugin' - '@kbn/cps-utils' - - '@kbn/as-code-data-views-schema' + - '@kbn/core-saved-objects-common' + - '@kbn/as-code-filters-constants' tags: - plugin - prod diff --git a/src/platform/plugins/shared/discover/public/__mocks__/services.ts b/src/platform/plugins/shared/discover/public/__mocks__/services.ts index 06fdca2f5f14b..75780617e17e3 100644 --- a/src/platform/plugins/shared/discover/public/__mocks__/services.ts +++ b/src/platform/plugins/shared/discover/public/__mocks__/services.ts @@ -330,6 +330,7 @@ export function createDiscoverServicesMock(): DiscoverServices { discoverFeatureFlags: { getCascadeLayoutEnabled: jest.fn(() => false), getIsEsqlDefault: jest.fn(() => false), + getEmbeddableTransformsEnabled: jest.fn(() => false), }, embeddableEditor: { isByValueEditor: jest.fn(() => false), diff --git a/src/platform/plugins/shared/discover/public/build_services.ts b/src/platform/plugins/shared/discover/public/build_services.ts index fbdc0a2ca3cfa..a5c74308a28df 100644 --- a/src/platform/plugins/shared/discover/public/build_services.ts +++ b/src/platform/plugins/shared/discover/public/build_services.ts @@ -73,6 +73,7 @@ import type { ProfilesManager } from './context_awareness'; import type { DiscoverEBTManager } from './ebt_manager'; import { CASCADE_LAYOUT_ENABLED_FEATURE_FLAG_KEY, + EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, IS_ESQL_DEFAULT_FEATURE_FLAG_KEY, } from './constants'; import { EmbeddableEditorService } from './plugin_imports/embeddable_editor_service'; @@ -93,6 +94,8 @@ export interface UrlTracker { export interface DiscoverFeatureFlags { getCascadeLayoutEnabled: () => boolean; getIsEsqlDefault: () => boolean; + /** When true, panel state uses Discover session API format (discover_session_id, session tabs). */ + getEmbeddableTransformsEnabled: () => boolean; } export interface DiscoverServices { @@ -208,6 +211,8 @@ export const buildServices = ({ core.featureFlags.getBooleanValue(CASCADE_LAYOUT_ENABLED_FEATURE_FLAG_KEY, false), getIsEsqlDefault: () => core.featureFlags.getBooleanValue(IS_ESQL_DEFAULT_FEATURE_FLAG_KEY, false), + getEmbeddableTransformsEnabled: () => + core.featureFlags.getBooleanValue(EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, false), }, docLinks: core.docLinks, embeddable: plugins.embeddable, diff --git a/src/platform/plugins/shared/discover/public/constants.ts b/src/platform/plugins/shared/discover/public/constants.ts index 427ad0fef6cc6..0e094bd32a145 100644 --- a/src/platform/plugins/shared/discover/public/constants.ts +++ b/src/platform/plugins/shared/discover/public/constants.ts @@ -12,4 +12,7 @@ export const ADHOC_DATA_VIEW_RENDER_EVENT = 'ad_hoc_data_view'; export const SEARCH_SESSION_ID_QUERY_PARAM = 'searchSessionId'; export const CASCADE_LAYOUT_ENABLED_FEATURE_FLAG_KEY = 'discover.cascadeLayoutEnabled'; + +export { EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY } from '../common'; + export { IS_ESQL_DEFAULT_FEATURE_FLAG_KEY } from '@kbn/discover-utils'; diff --git a/src/platform/plugins/shared/discover/public/embeddable/constants.ts b/src/platform/plugins/shared/discover/public/embeddable/constants.ts index 1a557c8b27057..47f8ba31745f3 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/constants.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/constants.ts @@ -23,10 +23,10 @@ export const ACTION_ADD_DISCOVER_SESSION_PANEL = 'ACTION_ADD_DISCOVER_SESSION_PA export const DEFAULT_HEADER_ROW_HEIGHT_LINES = 3; /** This constant refers to the dashboard panel specific state */ -export const EDITABLE_PANEL_KEYS: Readonly> = [ +export const EDITABLE_PANEL_KEYS = [ 'title', // panel title 'description', // panel description 'time_range', // panel custom time range 'hide_title', // panel hidden title 'drilldowns', // panel drilldowns -] as const; +] as const satisfies ReadonlyArray; diff --git a/src/platform/plugins/shared/discover/public/embeddable/get_legacy_log_stream_embeddable_factory.ts b/src/platform/plugins/shared/discover/public/embeddable/get_legacy_log_stream_embeddable_factory.ts index a731a3672e180..6c931dc017407 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/get_legacy_log_stream_embeddable_factory.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/get_legacy_log_stream_embeddable_factory.ts @@ -39,8 +39,8 @@ export const getLegacyLogStreamEmbeddableFactory = ( title: initialState.title, description: initialState.description, timeRange: initialState.time_range, - sort: initialState.sort ?? [], - columns: initialState.columns ?? [], + sort: 'sort' in initialState ? initialState.sort : [], + columns: 'columns' in initialState ? initialState.columns : [], searchSource, managed: false, }; diff --git a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx index b33711bb9ab07..97e8185d1bc43 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx +++ b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx @@ -26,7 +26,11 @@ import { createDataViewDataSource } from '../../common/data_sources'; import type { SearchEmbeddableState } from '../../common/embeddable/types'; import { discoverServiceMock } from '../__mocks__/services'; import { getSearchEmbeddableFactory } from './get_search_embeddable_factory'; -import type { SearchEmbeddableApi, SearchEmbeddableRuntimeState } from './types'; +import type { + SearchEmbeddableApi, + SearchEmbeddablePanelApiState, + SearchEmbeddableRuntimeState, +} from './types'; import { SolutionType } from '../context_awareness'; import { mockInitializeDrilldownsManager } from '@kbn/embeddable-plugin/public/mocks'; import { renderWithI18n } from '@kbn/test-jest-helpers'; @@ -128,7 +132,7 @@ describe('saved search embeddable', () => { }; const finalizeApiMock = ( - api: EmbeddableApiRegistration + api: EmbeddableApiRegistration ) => ({ ...api, applySerializedState: () => undefined, @@ -149,7 +153,7 @@ describe('saved search embeddable', () => { }; const finalizeEditableApiMock = ( - api: EmbeddableApiRegistration + api: EmbeddableApiRegistration ) => ({ ...api, applySerializedState: () => undefined, @@ -167,7 +171,7 @@ describe('saved search embeddable', () => { runtimeState = getInitialRuntimeState({ searchMock: search }); const { Component, api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -202,7 +206,7 @@ describe('saved search embeddable', () => { const { Component, api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -284,7 +288,7 @@ describe('saved search embeddable', () => { }); const { api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -442,7 +446,7 @@ describe('saved search embeddable', () => { runtimeState = getInitialRuntimeState(); await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -467,7 +471,7 @@ describe('saved search embeddable', () => { }; await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -494,7 +498,7 @@ describe('saved search embeddable', () => { runtimeState = getInitialRuntimeState(); const { api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -527,7 +531,7 @@ describe('saved search embeddable', () => { }); const { Component, api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, diff --git a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx index 246cd1747de14..1e781fd643d22 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx +++ b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx @@ -39,8 +39,7 @@ import { initializeEditApi } from './initialize_edit_api'; import { initializeFetch, isEsqlMode } from './initialize_fetch'; import { initializeInlineEditingApi } from './initialize_inline_editing_api'; import { initializeSearchEmbeddableApi } from './initialize_search_embeddable_api'; -import type { SearchEmbeddableState } from '../../common/embeddable/types'; -import type { SearchEmbeddableApi } from './types'; +import type { SearchEmbeddableApi, SearchEmbeddablePanelApiState } from './types'; import { deserializeState, serializeState } from './utils/serialization_utils'; import { ScopedServicesProvider } from '../components/scoped_services_provider'; import { isFieldStatsMode } from './utils/is_field_stats_mode'; @@ -60,7 +59,7 @@ export const getSearchEmbeddableFactory = ({ const { save, checkForDuplicateTitle } = discoverServices.savedSearch; const savedSearchEmbeddableFactory: EmbeddableFactory< - SearchEmbeddableState, + SearchEmbeddablePanelApiState, SearchEmbeddableApi > = { type: SEARCH_EMBEDDABLE_TYPE, @@ -71,6 +70,9 @@ export const getSearchEmbeddableFactory = ({ parentApi, uuid, }) => { + const embeddableTransformsEnabled = + discoverServices.discoverFeatureFlags.getEmbeddableTransformsEnabled(); + const runtimeState = await deserializeState({ serializedState: initialState, discoverServices, @@ -108,9 +110,9 @@ export const getSearchEmbeddableFactory = ({ const fetchWarnings$ = new BehaviorSubject([]); /** Build API */ - const titleManager = initializeTitleManager(initialState); - const timeRangeManager = initializeTimeRangeManager(initialState); - const drilldownsManager = await initializeDrilldownsManager(uuid, initialState); + const titleManager = initializeTitleManager(runtimeState); + const timeRangeManager = initializeTimeRangeManager(runtimeState); + const drilldownsManager = await initializeDrilldownsManager(uuid, runtimeState); const searchEmbeddable = await initializeSearchEmbeddableApi({ initialState: runtimeState, dataLoading$, @@ -127,6 +129,7 @@ export const getSearchEmbeddableFactory = ({ serializeDynamicActions: drilldownsManager.getLatestState, savedObjectId, selectedTabId: selectedTabId$.getValue(), + embeddableTransformsEnabled, }); const inlineEditingApi = initializeInlineEditingApi({ @@ -139,7 +142,7 @@ export const getSearchEmbeddableFactory = ({ dataLoading$, }); - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ uuid, parentApi, serializeState: () => serialize(savedObjectId$.getValue()), @@ -158,6 +161,19 @@ export const getSearchEmbeddableFactory = ({ const isDeleted = isSelectedTabDeleted(selectedTabId$.getValue()); const shouldSkipTabComparators = isDeleted || inlineEditingApi.isEditing(); + if (embeddableTransformsEnabled) { + const isByValue = !savedObjectId$.getValue(); + return { + ...drilldownsManager.comparators, + ...titleComparators, + ...timeRangeComparators, + discover_session_id: 'skip', + selected_tab_id: shouldSkipTabComparators ? 'skip' : 'referenceEquality', + overrides: shouldSkipTabComparators ? 'skip' : 'deepEquality', + tabs: !isByValue || shouldSkipTabComparators ? 'skip' : 'deepEquality', + }; + } + return { ...drilldownsManager.comparators, ...titleComparators, diff --git a/src/platform/plugins/shared/discover/public/embeddable/types.ts b/src/platform/plugins/shared/discover/public/embeddable/types.ts index d3c53af10216e..37953e3b9b7db 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/types.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/types.ts @@ -38,9 +38,18 @@ import type { PublishesWritableDataViews } from '@kbn/presentation-publishing/in import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; import type { NonPersistedDisplayOptions, - SearchEmbeddableState, + SearchEmbeddablePanelApiState, } from '../../common/embeddable/types'; +export type { SearchEmbeddablePanelApiState }; + +/** + * Input state accepted by the search embeddable factory. + */ +export type SearchEmbeddableInputState = SearchEmbeddablePanelApiState & { + nonPersistedDisplayOptions?: NonPersistedDisplayOptions; +}; + export type SearchEmbeddablePublicState = Pick< SerializableSavedSearch, | 'rowHeight' @@ -83,7 +92,7 @@ export type SearchEmbeddableRuntimeState = SearchEmbeddableSerializedAttributes tabs?: DiscoverSessionTab[]; }; -export type SearchEmbeddableApi = DefaultEmbeddableApi & +export type SearchEmbeddableApi = DefaultEmbeddableApi & PublishesSavedObjectId & PublishesDataLoading & PublishesBlockingError & diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/add_panel_from_library.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/add_panel_from_library.ts index c020e1216f6db..8a1499fec6148 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/add_panel_from_library.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/add_panel_from_library.ts @@ -12,6 +12,7 @@ import type { SavedSearchAttributes } from '@kbn/saved-search-plugin/common'; import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils'; import { apiPublishesESQLVariables } from '@kbn/esql-types'; import { apiHasUniqueId, apiPublishesEditablePauseFetch } from '@kbn/presentation-publishing'; +import type { DiscoverSessionEmbeddableState } from '../../../server'; import { addControlsFromSavedSession } from './add_controls_from_saved_session'; type OnAddParams = Parameters; @@ -30,11 +31,12 @@ export const addPanelFromLibrary: (...params: OnAddParams) => Promise = as const shouldPauseFetch = mightHaveVariables && apiPublishesEditablePauseFetch(container); if (shouldPauseFetch) container.setFetchPaused(true); - const api = await container.addNewPanel( + const api = await container.addNewPanel( { panelType: SEARCH_EMBEDDABLE_TYPE, serializedState: { - savedObjectId: savedObject.id, + discover_session_id: savedObject.id, + overrides: {}, }, }, { diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts index 7404ebf858487..f8080324b6264 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts @@ -7,61 +7,91 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { AS_CODE_DATA_VIEW_REFERENCE_TYPE } from '@kbn/as-code-data-views-schema'; import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; -import type { DiscoverSessionTab } from '@kbn/saved-search-plugin/common'; -import { toSavedSearchAttributes } from '@kbn/saved-search-plugin/common'; -import { createDiscoverSessionMock } from '@kbn/saved-search-plugin/common/mocks'; import { discoverServiceMock } from '../../__mocks__/services'; import { getPersistedTabMock } from '../../application/main/state_management/redux/__mocks__/internal_state.mocks'; +import { deserializeState, serializeState } from './serialization_utils'; import type { + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableByValueState, +} from '../../../server'; +import type { SortOrder } from '@kbn/saved-search-plugin/public'; +import type { DiscoverSessionTab } from '@kbn/saved-search-plugin/common'; +import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; +import { DataGridDensity } from '@kbn/discover-utils'; +import { createDiscoverSessionMock } from '@kbn/saved-search-plugin/common/mocks'; +import type { + SearchEmbeddableByReferenceState, SearchEmbeddableByValueState, - SearchEmbeddableState, } from '../../../common/embeddable/types'; -import { deserializeState, serializeState } from './serialization_utils'; -import type { DiscoverSessionTab as DiscoverSessionTabSchema } from '@kbn/saved-search-plugin/server'; +import type { DiscoverServices } from '../../build_services'; describe('Serialization utils', () => { const uuid = 'mySearchEmbeddable'; - const tabs: DiscoverSessionTabSchema[] = [ - { - id: 'tab-1', - label: 'Tab 1', - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: '{"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', - }, - sort: [['order_date', 'desc']], - columns: ['_source'], - grid: {}, - hideChart: false, - hideTable: false, - sampleSize: 100, - isTextBasedQuery: false, - }, + const dataViewId = dataViewMock.id ?? 'test-id'; + + const discoverServicesLegacy = { + ...discoverServiceMock, + discoverFeatureFlags: { + getCascadeLayoutEnabled: jest.fn(() => false), + getIsEsqlDefault: jest.fn(() => false), + getEmbeddableTransformsEnabled: jest.fn(() => false), }, - ]; + } satisfies DiscoverServices; + const mockedSavedSearchAttributes: SearchEmbeddableByValueState['attributes'] = { kibanaSavedObjectMeta: { searchSourceJSON: '{"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', }, title: 'test1', + description: 'description', sort: [['order_date', 'desc']], columns: ['_source'], - description: 'description', grid: {}, hideChart: false, hideTable: false, sampleSize: 100, isTextBasedQuery: false, - tabs, - references: [ + tabs: [ + { + id: 'tab-1', + label: 'Tab 1', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: '{"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', + }, + sort: [['order_date', 'desc']], + columns: ['_source'], + grid: {}, + hideChart: false, + hideTable: false, + sampleSize: 100, + isTextBasedQuery: false, + }, + }, + ], + }; + + /** Minimal API shape for by-value (DiscoverSessionEmbeddableByValueState) */ + const apiStateByValue: DiscoverSessionEmbeddableByValueState = { + title: 'test panel title', + description: 'description', + tabs: [ { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - id: dataViewMock.id ?? 'test-id', - type: 'index-pattern', + column_order: ['_source'], + sort: [{ name: 'order_date', direction: 'desc' }], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 'auto', + row_height: 'auto', + query: { language: 'kuery', query: '' }, + filters: [], + rows_per_page: 100, + sample_size: 100, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: dataViewId }, }, ], }; @@ -95,20 +125,30 @@ describe('Serialization utils', () => { describe('deserialize state', () => { test('by value', async () => { - const serializedState: SearchEmbeddableState = { - attributes: mockedSavedSearchAttributes, - title: 'test panel title', - }; - const deserializedState = await deserializeState({ - serializedState, + serializedState: apiStateByValue, discoverServices: discoverServiceMock, }); - expect(discoverServiceMock.savedSearch.byValueToSavedSearch).toBeCalledWith( - serializedState, - true // should be serializable + expect(discoverServiceMock.savedSearch.byValueToSavedSearch).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + title: 'test panel title', + description: 'description', + columns: ['_source'], + sort: [['order_date', 'desc']], + kibanaSavedObjectMeta: expect.objectContaining({ + searchSourceJSON: expect.any(String), + }), + }), + }), + true ); + const byValueCall = discoverServiceMock.savedSearch.byValueToSavedSearch as jest.Mock; + const [firstArg] = byValueCall.mock.calls[0]; + expect(firstArg.attributes.references).toBeDefined(); + expect(Array.isArray(firstArg.attributes.references)).toBe(true); + expect(Object.keys(deserializedState)).toContain('serializedSearchSource'); expect(deserializedState.title).toEqual('test panel title'); }); @@ -125,18 +165,44 @@ describe('Serialization utils', () => { .fn() .mockResolvedValue(mockDiscoverSession(sessionTabs)); - const serializedState: SearchEmbeddableState = { + const apiStateByRef: DiscoverSessionEmbeddableByReferenceState = { title: 'test panel title', - sort: [['order_date', 'asc']], // overwrite the saved object sort - savedObjectId: 'savedSearch', + description: 'My description', + discover_session_id: 'savedSearch', + selected_tab_id: undefined, + overrides: {}, }; const deserializedState = await deserializeState({ - serializedState, + serializedState: apiStateByRef, discoverServices: discoverServiceMock, }); + expect(Object.keys(deserializedState)).toContain('serializedSearchSource'); expect(Object.keys(deserializedState)).toContain('savedObjectId'); + expect(deserializedState.savedObjectId).toBe('savedSearch'); + expect(deserializedState.title).toEqual('test panel title'); + }); + + test('by reference with panel overwrites', async () => { + const sessionTabs = [mockTab('tab-1', 'Tab 1')]; + discoverServiceMock.savedSearch.getDiscoverSession = jest + .fn() + .mockResolvedValue(mockDiscoverSession(sessionTabs)); + + const apiStateByRef: DiscoverSessionEmbeddableByReferenceState = { + title: 'test panel title', + description: 'My description', + discover_session_id: 'savedSearch', + selected_tab_id: undefined, + overrides: { sort: [{ name: 'order_date', direction: 'asc' }] }, + }; + + const deserializedState = await deserializeState({ + serializedState: apiStateByRef, + discoverServices: discoverServiceMock, + }); + expect(deserializedState.title).toEqual('test panel title'); // For a valid/default tab, dashboard overrides win on top of resolved tab attributes expect(deserializedState.sort).toEqual([['order_date', 'asc']]); @@ -158,10 +224,11 @@ describe('Serialization utils', () => { .fn() .mockResolvedValue(mockDiscoverSession(sessionTabs)); - const serializedState: SearchEmbeddableState = { + const serializedState: DiscoverSessionEmbeddableByReferenceState = { title: 'test panel title', - savedObjectId: 'savedSearch', - selectedTabId: 'tab-2', + discover_session_id: 'savedSearch', + selected_tab_id: 'tab-2', + overrides: {}, }; const deserializedState = await deserializeState({ @@ -187,13 +254,14 @@ describe('Serialization utils', () => { .fn() .mockResolvedValue(mockDiscoverSession(sessionTabs)); - const serializedState: SearchEmbeddableState = { + const serializedState: DiscoverSessionEmbeddableByReferenceState = { title: 'test panel title', - savedObjectId: 'savedSearch', - selectedTabId: 'deleted-tab-id', - // Stale overrides from the deleted tab - columns: ['stale-col-a'], - sort: [['stale_field', 'asc']], + discover_session_id: 'savedSearch', + selected_tab_id: 'deleted-tab-id', + overrides: { + column_order: ['stale-col-a'], + sort: [{ name: 'stale_field', direction: 'asc' }], + }, }; const deserializedState = await deserializeState({ @@ -218,12 +286,11 @@ describe('Serialization utils', () => { .fn() .mockResolvedValue(mockDiscoverSession(sessionTabs)); - const serializedState: SearchEmbeddableState = { + const serializedState: DiscoverSessionEmbeddableByReferenceState = { title: 'test panel title', - savedObjectId: 'savedSearch', - selectedTabId: 'tab-2', - // Dashboard override for columns on top of tab-2 - columns: ['custom-col'], + discover_session_id: 'savedSearch', + selected_tab_id: 'tab-2', + overrides: { column_order: ['custom-col'] }, }; const deserializedState = await deserializeState({ @@ -238,11 +305,19 @@ describe('Serialization utils', () => { describe('serialize state', () => { test('by value', () => { + const sort: SortOrder[] = [['order_date', 'desc']]; const searchSource = createSearchSourceMock({ index: dataViewMock, }); const savedSearch = { - ...mockedSavedSearchAttributes, + title: 'test1', + description: 'description', + columns: ['_source'], + sort, + grid: {}, + hideChart: false, + sampleSize: 100, + isTextBasedQuery: false, managed: false, searchSource, }; @@ -252,37 +327,45 @@ describe('Serialization utils', () => { initialState: { ...mockedSavedSearchAttributes, tabs: [], - serializedSearchSource: {} as SerializedSearchSourceFields, + serializedSearchSource: {}, }, savedSearch, - serializeTitles: jest.fn(), + serializeTitles: jest.fn().mockReturnValue({ title: 'test1', description: 'description' }), serializeTimeRange: jest.fn(), serializeDynamicActions: jest.fn(), + embeddableTransformsEnabled: true, }); - const searchSourceJSON = JSON.stringify(searchSource.getSerializedFields()); - const attributes = toSavedSearchAttributes(savedSearch, searchSourceJSON); - - expect(serializedState).toEqual({ - attributes: { - ...attributes, - tabs: [ - { - ...attributes.tabs![0]!, - id: expect.any(String), - }, - ], - }, + expect(serializedState).toMatchObject({ + title: 'test1', + description: 'description', + tabs: [ + expect.objectContaining({ + column_order: ['_source'], + sort: [{ name: 'order_date', direction: 'desc' }], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: dataViewId }, + }), + ], }); + expect(serializedState).not.toHaveProperty('attributes'); }); describe('by reference', () => { + const sort: SortOrder[] = [['order_date', 'desc']]; const searchSource = createSearchSourceMock({ index: dataViewMock, }); - const savedSearch = { - ...mockedSavedSearchAttributes, + title: 'test1', + description: 'description', + columns: ['_source'], + sort, + grid: {}, + hideChart: false, + sampleSize: 100, + isTextBasedQuery: false, managed: false, searchSource, }; @@ -293,37 +376,44 @@ describe('Serialization utils', () => { initialState: { tabs: [mockTab('tab-1', 'Tab 1')], }, - savedSearch, + savedSearch: savedSearch as Parameters[0]['savedSearch'], serializeTitles: jest.fn(), serializeTimeRange: jest.fn(), serializeDynamicActions: jest.fn(), savedObjectId: 'test-id', + embeddableTransformsEnabled: true, }); - expect(serializedState).toEqual({ - savedObjectId: 'test-id', + expect(serializedState).toMatchObject({ + discover_session_id: 'test-id', }); + expect(serializedState).not.toHaveProperty('savedObjectId'); }); test('overwrite state', () => { + const sortOverride: SortOrder[] = [['order_date', 'asc']]; const serializedState = serializeState({ uuid, initialState: { tabs: [mockTab('tab-1', 'Tab 1')], }, - savedSearch: { ...savedSearch, sampleSize: 500, sort: [['order_date', 'asc']] }, + savedSearch: { + ...savedSearch, + sampleSize: 500, + sort: sortOverride, + } as Parameters[0]['savedSearch'], serializeTitles: jest.fn(), serializeTimeRange: jest.fn(), serializeDynamicActions: jest.fn(), savedObjectId: 'test-id', selectedTabId: 'tab-1', + embeddableTransformsEnabled: true, }); - expect(serializedState).toEqual({ - sampleSize: 500, - sort: [['order_date', 'asc']], - savedObjectId: 'test-id', - selectedTabId: 'tab-1', + // By-reference API shape includes discover_session_id; panel overrides (sampleSize, sort) + // are stored in the dashboard document but not part of the simplified by-ref schema + expect(serializedState).toMatchObject({ + discover_session_id: 'test-id', }); }); @@ -333,17 +423,18 @@ describe('Serialization utils', () => { initialState: { tabs: [mockTab('tab-1', 'Tab 1'), mockTab('tab-2', 'Tab 2')], }, - savedSearch, + savedSearch: savedSearch as Parameters[0]['savedSearch'], serializeTitles: jest.fn(), serializeTimeRange: jest.fn(), serializeDynamicActions: jest.fn(), savedObjectId: 'test-id', selectedTabId: 'tab-2', + embeddableTransformsEnabled: true, }); - expect(serializedState).toEqual({ - savedObjectId: 'test-id', - selectedTabId: 'tab-2', + expect(serializedState).toMatchObject({ + discover_session_id: 'test-id', + selected_tab_id: 'tab-2', }); }); @@ -353,18 +444,85 @@ describe('Serialization utils', () => { initialState: { tabs: [mockTab('tab-1', 'Tab 1')], }, - savedSearch, + savedSearch: savedSearch as Parameters[0]['savedSearch'], serializeTitles: jest.fn(), serializeTimeRange: jest.fn(), serializeDynamicActions: jest.fn(), savedObjectId: 'test-id', selectedTabId: undefined, + embeddableTransformsEnabled: true, }); - expect(serializedState).toEqual({ - savedObjectId: 'test-id', + expect(serializedState).toMatchObject({ + discover_session_id: 'test-id', }); }); }); }); + + describe('legacy panel state (embeddable transforms disabled)', () => { + test('deserialize by-ref uses savedObjectId', async () => { + const sessionTabs = [mockTab('tab-1', 'Tab 1')]; + discoverServicesLegacy.savedSearch.getDiscoverSession = jest + .fn() + .mockResolvedValue(mockDiscoverSession(sessionTabs)); + + const legacyByRef: SearchEmbeddableByReferenceState = { + title: 'Panel title', + savedObjectId: 'legacy-session-id', + }; + + const deserialized = await deserializeState({ + serializedState: legacyByRef, + discoverServices: discoverServicesLegacy, + }); + + expect(deserialized.savedObjectId).toBe('legacy-session-id'); + expect(discoverServicesLegacy.savedSearch.getDiscoverSession).toHaveBeenCalledWith( + 'legacy-session-id' + ); + }); + + test('deserialize Discover session by-value when transforms disabled (add-to-dashboard)', async () => { + const deserializedState = await deserializeState({ + serializedState: apiStateByValue, + discoverServices: discoverServicesLegacy, + }); + + expect(discoverServicesLegacy.savedSearch.byValueToSavedSearch).toHaveBeenCalled(); + expect(Object.keys(deserializedState)).toContain('serializedSearchSource'); + expect(deserializedState.title).toEqual('test panel title'); + }); + + test('serialize by-ref returns savedObjectId (not discover_session_id)', () => { + const sort: SortOrder[] = [['order_date', 'desc']]; + const searchSource = createSearchSourceMock({ index: dataViewMock }); + const savedSearch = { + title: 'test1', + description: 'description', + columns: ['_source'], + sort, + grid: {}, + hideChart: false, + sampleSize: 100, + isTextBasedQuery: false, + managed: false, + searchSource, + }; + + const serialized = serializeState({ + uuid, + initialState: { tabs: [mockTab('tab-1', 'Tab 1')] }, + savedSearch: savedSearch as Parameters[0]['savedSearch'], + serializeTitles: jest.fn(), + serializeTimeRange: jest.fn(), + serializeDynamicActions: jest.fn(), + savedObjectId: 'legacy-id', + embeddableTransformsEnabled: false, + }); + + expect(serialized).toMatchObject({ savedObjectId: 'legacy-id' }); + expect(serialized).not.toHaveProperty('discover_session_id'); + }); + }); }); diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts index 27353d77f3118..3a7d5ba275dba 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts @@ -10,46 +10,53 @@ import { omit, pick } from 'lodash'; import deepEqual from 'react-fast-compare'; import { type SerializedTimeRange, type SerializedTitles } from '@kbn/presentation-publishing'; -import { toSavedSearchAttributes, type SavedSearch } from '@kbn/saved-search-plugin/common'; +import { type SavedSearch, toSavedSearchAttributes } from '@kbn/saved-search-plugin/common'; import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; -import { EDITABLE_SAVED_SEARCH_KEYS } from '../../../common/embeddable/constants'; import type { EditableSavedSearchAttributes, SearchEmbeddableByReferenceState, - SearchEmbeddableByValueState, - SearchEmbeddableState, + SearchEmbeddablePanelApiState, + StoredSearchEmbeddableByValueState, } from '../../../common/embeddable/types'; +import { + fromStoredSearchEmbeddable, + fromStoredSearchEmbeddableByRef, + fromStoredSearchEmbeddableByValue, + isDiscoverSessionEmbeddableByReferenceState, + isSearchEmbeddableLegacyPanelState, + toStoredSearchEmbeddableByValue, + fromDiscoverSessionPanelOverrides, +} from '../../../common/embeddable'; +import { EDITABLE_SAVED_SEARCH_KEYS } from '../../../common/embeddable/constants'; import type { DiscoverServices } from '../../build_services'; import { EDITABLE_PANEL_KEYS } from '../constants'; -import type { SearchEmbeddableRuntimeState } from '../types'; +import type { SearchEmbeddableInputState, SearchEmbeddableRuntimeState } from '../types'; import { isTabDeleted } from './is_tab_deleted'; export const deserializeState = async ({ serializedState, discoverServices, }: { - serializedState: SearchEmbeddableState; + serializedState: SearchEmbeddableInputState; discoverServices: DiscoverServices; }): Promise => { const panelState = pick(serializedState, EDITABLE_PANEL_KEYS); - const savedObjectId = (serializedState as SearchEmbeddableByReferenceState).savedObjectId; - if (savedObjectId) { + const apiState = isSearchEmbeddableLegacyPanelState(serializedState) + ? fromStoredSearchEmbeddable(serializedState) + : serializedState; + + if (isDiscoverSessionEmbeddableByReferenceState(apiState)) { // by reference + const { discover_session_id: savedObjectId, selected_tab_id: selectedTabId } = apiState; const { getDiscoverSession } = discoverServices.savedSearch; const session = await getDiscoverSession(savedObjectId); - - const selectedTabId = (serializedState as SearchEmbeddableByReferenceState).selectedTabId; const selectedTab = selectedTabId ? session.tabs.find((t) => t.id === selectedTabId) : undefined; - const resolvedTab = selectedTab ?? session.tabs[0]; - const isSelectedTabDeleted = Boolean(selectedTabId && !selectedTab); - const resolvedSelectedTabId = isSelectedTabDeleted ? selectedTabId : resolvedTab?.id; - - const savedObjectOverride = pick(serializedState, EDITABLE_SAVED_SEARCH_KEYS); + const savedObjectOverride = fromDiscoverSessionPanelOverrides(apiState.overrides ?? {}); // Build runtime state from the resolved tab's attributes // ignore the time range from the tab - only global time range + panel time range matter @@ -70,10 +77,13 @@ export const deserializeState = async ({ }; } else { // by value + const [tab] = apiState.tabs; + const savedObjectOverride = fromDiscoverSessionPanelOverrides(tab ?? {}); const { byValueToSavedSearch } = discoverServices.savedSearch; + const { state: storedState, references } = toStoredSearchEmbeddableByValue(apiState); const savedSearch = await byValueToSavedSearch( - serializedState as SearchEmbeddableByValueState, + { attributes: { ...storedState.attributes, references } }, true ); @@ -82,6 +92,7 @@ export const deserializeState = async ({ return { ...savedSearchWithoutTabs, ...panelState, + ...savedObjectOverride, nonPersistedDisplayOptions: serializedState.nonPersistedDisplayOptions, }; } @@ -96,6 +107,7 @@ export const serializeState = ({ serializeDynamicActions, savedObjectId, selectedTabId, + embeddableTransformsEnabled, }: { uuid: string; initialState: SearchEmbeddableRuntimeState; @@ -105,7 +117,8 @@ export const serializeState = ({ serializeDynamicActions: () => SerializedDrilldowns; savedObjectId?: string; selectedTabId?: string; -}): SearchEmbeddableState => { + embeddableTransformsEnabled: boolean; +}): SearchEmbeddablePanelApiState => { const searchSource = savedSearch.searchSource; const searchSourceJSON = JSON.stringify(searchSource.getSerializedFields()); const savedSearchAttributes = toSavedSearchAttributes(savedSearch, searchSourceJSON); @@ -134,21 +147,22 @@ export const serializeState = ({ }, {}); } - return { - // Serialize the current dashboard state into the panel state **without** updating the saved object + const stored: SearchEmbeddableByReferenceState = { ...serializeTitles(), ...serializeTimeRange(), ...serializeDynamicActions?.(), ...overwriteState, + ...(selectedTabId !== undefined && { selectedTabId }), savedObjectId, - ...(selectedTabId ? { selectedTabId } : {}), }; + return embeddableTransformsEnabled ? fromStoredSearchEmbeddableByRef(stored) : stored; } - return { + const stored: StoredSearchEmbeddableByValueState = { ...serializeTitles(), ...serializeTimeRange(), ...serializeDynamicActions?.(), attributes: savedSearchAttributes, }; + return embeddableTransformsEnabled ? fromStoredSearchEmbeddableByValue(stored, []) : stored; }; diff --git a/src/platform/plugins/shared/discover/public/index.ts b/src/platform/plugins/shared/discover/public/index.ts index d6b85ff35f4d4..51e36ad447410 100644 --- a/src/platform/plugins/shared/discover/public/index.ts +++ b/src/platform/plugins/shared/discover/public/index.ts @@ -34,6 +34,7 @@ export { type HasTimeRange, type SearchEmbeddableRuntimeState, type SearchEmbeddableApi, + type SearchEmbeddablePanelApiState, } from './embeddable'; export type { DiscoverServices } from './build_services'; diff --git a/src/platform/plugins/shared/discover/public/plugin.tsx b/src/platform/plugins/shared/discover/public/plugin.tsx index d7de974280a07..026b3e68b556a 100644 --- a/src/platform/plugins/shared/discover/public/plugin.tsx +++ b/src/platform/plugins/shared/discover/public/plugin.tsx @@ -29,7 +29,6 @@ import { DISCOVER_ESQL_LOCATOR } from '@kbn/deeplinks-analytics'; import { ADD_PANEL_TRIGGER, ON_OPEN_PANEL_MENU } from '@kbn/ui-actions-plugin/common/trigger_ids'; import type { DrilldownTransforms } from '@kbn/embeddable-plugin/common'; import { ProjectRoutingAccess } from '@kbn/cps-utils'; -import { registerUnifiedChartSectionViewerEbtEvents } from '@kbn/unified-chart-section-viewer/src/analytics'; import { DISCOVER_APP_LOCATOR, PLUGIN_ID, type DiscoverAppLocator } from '../common'; import { DISCOVER_CONTEXT_APP_LOCATOR, @@ -171,7 +170,10 @@ export class DiscoverPlugin }); registerDiscoverEBTManagerAnalytics(core, this.discoverEbtContext$); - registerUnifiedChartSectionViewerEbtEvents(core.analytics); + void getUnifiedChartSectionViewerAnalytics().then( + ({ registerUnifiedChartSectionViewerEbtEvents }) => + registerUnifiedChartSectionViewerEbtEvents(core.analytics) + ); core.application.register({ id: PLUGIN_ID, @@ -475,8 +477,11 @@ export class DiscoverPlugin plugins.embeddable.registerLegacyURLTransform( SEARCH_EMBEDDABLE_TYPE, async (transformDrilldownsOut: DrilldownTransforms['transformOut']) => { + const discoverServices = await getDiscoverServicesForEmbeddable(); const { getTransformOut } = await getEmbeddableServices(); - return getTransformOut(transformDrilldownsOut); + return getTransformOut(transformDrilldownsOut, () => + discoverServices.discoverFeatureFlags.getEmbeddableTransformsEnabled() + ); } ); } @@ -485,6 +490,8 @@ export class DiscoverPlugin const getLocators = () => import('./plugin_imports/locators'); const getEmbeddableServices = () => import('./plugin_imports/embeddable_services'); const getSharedServices = () => import('./plugin_imports/shared_services'); +const getUnifiedChartSectionViewerAnalytics = () => + import('@kbn/unified-chart-section-viewer/src/analytics'); const getHistoryService = once(async () => { const { HistoryService } = await getSharedServices(); diff --git a/src/platform/plugins/shared/discover/server/embeddable/index.ts b/src/platform/plugins/shared/discover/server/embeddable/index.ts index e206486a980f0..b725929a76736 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/index.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/index.ts @@ -8,3 +8,12 @@ */ export { createSearchEmbeddableFactory } from './search_embeddable_factory'; +export type { + DiscoverSessionClassicTab, + DiscoverSessionEsqlTab, + DiscoverSessionTab, + DiscoverSessionPanelOverrides, + DiscoverSessionEmbeddableByValueState, + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableState, +} from './schema'; diff --git a/src/platform/plugins/shared/discover/server/embeddable/schema.ts b/src/platform/plugins/shared/discover/server/embeddable/schema.ts index 5810c6fea6adb..0ef7e3f8856df 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -7,21 +7,23 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { TypeOf } from '@kbn/config-schema'; +import type { ObjectType, Props, TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; import { DataGridDensity } from '@kbn/discover-utils'; -import { aggregateQuerySchema, querySchema, timeRangeSchema } from '@kbn/es-query-server'; -import { serializedTitlesSchema } from '@kbn/presentation-publishing-schemas'; +import { aggregateQuerySchema, querySchema } from '@kbn/es-query-server'; +import { + BY_REF_SCHEMA_META, + BY_VALUE_SCHEMA_META, + serializedTitlesSchema, + serializedTimeRangeSchema, +} from '@kbn/presentation-publishing-schemas'; import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; import { asCodeFilterSchema } from '@kbn/as-code-filters-schema'; -import { runtimeFieldSchema } from '@kbn/as-code-data-views-schema'; +import { dataViewSchema } from '@kbn/as-code-data-views-schema'; +import type { GetDrilldownsSchemaFnType } from '@kbn/embeddable-plugin/server'; +import { ON_OPEN_PANEL_MENU } from '@kbn/ui-actions-plugin/common/trigger_ids'; -const columnSchema = schema.object({ - name: schema.string({ - meta: { - description: 'The name of the field to display in the data table.', - }, - }), +const columnSettingsEntrySchema = schema.object({ width: schema.maybe( schema.number({ min: 0, @@ -46,84 +48,34 @@ const sortSchema = schema.object({ }), }); -/** - * TODO: These are duplicated from the Lens embeddable schema. We should move these to a shared location. - * @see datasetTypeSchema - */ -export const dataViewReferenceSchema = schema.object( +export const viewModeSchema = schema.oneOf( + [ + schema.literal(VIEW_MODE.DOCUMENT_LEVEL), + schema.literal(VIEW_MODE.PATTERN_LEVEL), + schema.literal(VIEW_MODE.AGGREGATED_LEVEL), + ], { - type: schema.literal('dataView'), - /** - * The name of the Kibana data view to use as the data source. - * Example: 'my-data-view' - */ - id: schema.string({ - meta: { - description: - 'The id of the Kibana data view to use as the data source. Example: "my-data-view".', - }, - }), - }, - { meta: { id: 'dataViewDatasetTypeSchema' } } + defaultValue: VIEW_MODE.DOCUMENT_LEVEL, + meta: { + description: + 'Discover view mode. Choose "documents" (search hits), "patterns" (pattern analysis), or "aggregated" (field statistics).', + }, + } ); -export const dataViewSpecSchema = schema.object( +const dataTableLimitsSchema = schema.object( { - type: schema.literal('index'), - /** - * The name of the Elasticsearch index to use as the data source. - * Example: 'my-index-*' - */ - index: schema.string({ - meta: { - description: - 'The name of the Elasticsearch index to use as the data source. Example: "my-index-*".', - }, - }), - /** - * The name of the time field in the index. Used for time-based filtering. - * Example: '@timestamp' - */ - time_field: schema.maybe( - schema.string({ + rows_per_page: schema.maybe( + schema.number({ + min: 1, + max: 10000, + defaultValue: 100, meta: { description: - 'The name of the time field in the index. Used for time-based filtering. Example: "@timestamp".', + 'The number of rows to display per page in the data table. If omitted, defaults to the advanced setting "discover:sampleRowsPerPage".', }, }) ), - /** - * Optional array of runtime fields to define on the index. Each runtime field describes a computed field available at query time. - * If not provided, no runtime fields are used. - */ - runtime_fields: schema.maybe(schema.arrayOf(runtimeFieldSchema, { maxSize: 100 })), - }, - { meta: { id: 'indexDatasetTypeSchema' } } -); - -export const dataViewSchema = schema.oneOf([dataViewReferenceSchema, dataViewSpecSchema]); - -const dataTableLimitsSchema = schema.object( - { - rows_per_page: schema.maybe( - schema.oneOf( - [ - schema.literal(10), - schema.literal(25), - schema.literal(50), - schema.literal(100), - schema.literal(250), - schema.literal(500), - ], - { - defaultValue: 100, - meta: { - description: - 'The number of rows to display per page in the data table. If omitted, defaults to the advanced setting "discover:sampleRowsPerPage".', - }, - } - ) - ), sample_size: schema.maybe( schema.number({ min: 10, @@ -141,13 +93,28 @@ const dataTableLimitsSchema = schema.object( const dataTableSchema = schema.object( { - columns: schema.maybe( - schema.arrayOf(columnSchema, { - maxSize: 100, - defaultValue: [], + column_order: schema.maybe( + schema.arrayOf( + schema.string({ + meta: { + description: 'Field name of a column in display order.', + }, + }), + { + maxSize: 100, + defaultValue: [], + meta: { + description: + 'Ordered list of field names to display in the data table. If omitted, defaults to the advanced setting "defaultColumns" or the referenced saved object.', + }, + } + ) + ), + column_settings: schema.maybe( + schema.recordOf(schema.string(), columnSettingsEntrySchema, { meta: { description: - 'List of columns to display in the data table. If omitted, defaults to the advanced setting "defaultColumns".', + 'Per-column presentation settings keyed by field name (e.g. widths). Keys should correspond to entries in `column_order` when both are set.', }, }) ), @@ -158,47 +125,132 @@ const dataTableSchema = schema.object( description: 'Sort configuration for the data table (field and direction).', }, }), - view_mode: schema.oneOf( - [ - schema.literal(VIEW_MODE.DOCUMENT_LEVEL), - schema.literal(VIEW_MODE.PATTERN_LEVEL), - schema.literal(VIEW_MODE.AGGREGATED_LEVEL), - ], - { - defaultValue: VIEW_MODE.DOCUMENT_LEVEL, + density: schema.maybe( + schema.oneOf( + [ + schema.literal(DataGridDensity.COMPACT), + schema.literal(DataGridDensity.EXPANDED), + schema.literal(DataGridDensity.NORMAL), + ], + { + defaultValue: DataGridDensity.COMPACT, + meta: { + description: + 'Data grid density. Choose "compact", "expanded", or "normal" for row spacing. If omitted, defaults to Discover or embeddable defaults (e.g. user preference / local storage).', + }, + } + ) + ), + header_row_height: schema.maybe( + schema.oneOf( + [ + schema.number({ + min: 1, + max: 5, + }), + schema.literal('auto'), + ], + { + defaultValue: 3, + meta: { + description: + 'Header row height. Use a number (1–5) or "auto" to size based on content. If omitted, defaults to Discover or embeddable defaults (e.g. user preference / local storage).', + }, + } + ) + ), + row_height: schema.maybe( + schema.oneOf( + [ + schema.number({ + min: 1, + max: 20, + }), + schema.literal('auto'), + ], + { + defaultValue: 3, + meta: { + description: + 'Data row height. Use a number (1–20) or "auto" to size based on content. If omitted, defaults to the advanced setting "discover:rowHeightOption".', + }, + } + ) + ), + }, + { meta: { id: 'discoverSessionEmbeddableDataTableSchema' } } +); + +const panelOverridesSchema = schema.object( + { + column_order: schema.maybe( + schema.arrayOf( + schema.string({ + meta: { + description: 'Field name of a column in display order.', + }, + }), + { + maxSize: 100, + defaultValue: [], + meta: { + description: + 'When set, overrides column order for the data table relative to the referenced saved object (`discover_session_id`) or the inline tab in `tabs`. If omitted, the source configuration is used.', + }, + } + ) + ), + column_settings: schema.maybe( + schema.recordOf(schema.string(), columnSettingsEntrySchema, { meta: { description: - 'Discover view mode. Choose "documents" (search hits), "patterns" (pattern analysis), or "aggregated" (field statistics).', + 'Per-column presentation overrides (e.g. widths) keyed by field name. When set, merges with the source configuration for the referenced session or inline tab.', }, - } + }) ), - density: schema.oneOf( - [ - schema.literal(DataGridDensity.COMPACT), - schema.literal(DataGridDensity.EXPANDED), - schema.literal(DataGridDensity.NORMAL), - ], - { - defaultValue: DataGridDensity.COMPACT, + sort: schema.maybe( + schema.arrayOf(sortSchema, { + maxSize: 100, + defaultValue: [], meta: { description: - 'Data grid density. Choose "compact", "expanded", or "normal" for row spacing.', + 'Sort configuration (field and direction) for the data table. When set, overrides the referenced saved object or the inline tab config in `tabs`. If omitted, the source configuration is used.', }, - } + }) ), - header_row_height: schema.oneOf( - [ - schema.number({ - min: 1, - max: 5, - }), - schema.literal('auto'), - ], - { - meta: { - description: 'Header row height. Use a number (1–5) or "auto" to size based on content.', - }, - } + density: schema.maybe( + schema.oneOf( + [ + schema.literal(DataGridDensity.COMPACT), + schema.literal(DataGridDensity.EXPANDED), + schema.literal(DataGridDensity.NORMAL), + ], + { + defaultValue: DataGridDensity.COMPACT, + meta: { + description: + 'Data grid row spacing: `compact`, `expanded`, or `normal`. When set, overrides the referenced saved object or the inline tab config in `tabs`. If omitted, the source configuration is used.', + }, + } + ) + ), + header_row_height: schema.maybe( + schema.oneOf( + [ + schema.number({ + min: 1, + max: 5, + }), + schema.literal('auto'), + ], + { + defaultValue: 3, + meta: { + description: + 'Header row height: number (1–5) or `auto`. When set, overrides the referenced saved object or the inline tab config in `tabs`. If omitted, the source configuration is used.', + }, + } + ) ), row_height: schema.maybe( schema.oneOf( @@ -213,13 +265,35 @@ const dataTableSchema = schema.object( defaultValue: 3, meta: { description: - 'Data row height. Use a number (1–20) or "auto" to size based on content. If omitted, defaults to the advanced setting "discover:rowHeightOption".', + 'Data row height: number (1–20) or `auto`. When set, overrides the referenced saved object or the inline tab config in `tabs`. If omitted, falls back to the source or to the advanced setting "discover:rowHeightOption".', }, } ) ), + rows_per_page: schema.maybe( + schema.number({ + min: 1, + max: 10000, + defaultValue: 100, + meta: { + description: + 'Number of rows per page. When set, overrides the referenced saved object or the inline tab config in `tabs`. If omitted, falls back to the source or to the advanced setting "discover:sampleRowsPerPage".', + }, + }) + ), + sample_size: schema.maybe( + schema.number({ + min: 10, + max: 10000, + defaultValue: 500, + meta: { + description: + 'Number of documents to sample. When set, overrides the referenced saved object or the inline tab config in `tabs`. If omitted, falls back to the source or to the advanced setting "discover:sampleSize".', + }, + }) + ), }, - { meta: { id: 'discoverSessionEmbeddableDataTableSchema' } } + { defaultValue: {} } ); const classicTabSchema = schema.allOf([ @@ -234,7 +308,8 @@ const classicTabSchema = schema.allOf([ description: 'List of filters to apply to the data in the tab.', }, }), - dataset: dataViewSchema, + data_source: dataViewSchema, + view_mode: viewModeSchema, }), ]); @@ -252,43 +327,77 @@ const esqlTabSchema = schema.allOf([ const tabSchema = schema.oneOf([classicTabSchema, esqlTabSchema]); -const discoverSessionBaseEmbeddableSchema = serializedTitlesSchema.extends({ - timeRange: schema.maybe(timeRangeSchema), // Waiting on https://github.com/elastic/kibana/pull/253789 -}); +const DISCOVER_SUPPORTED_DRILLDOWN_TRIGGERS = [ON_OPEN_PANEL_MENU]; -const discoverSessionByValueEmbeddableSchema = discoverSessionBaseEmbeddableSchema.extends({ - tabs: schema.arrayOf(tabSchema, { - minSize: 1, - maxSize: 1, - meta: { - description: 'Array of tabs for the Discover session embeddable. Currently supports one tab.', - }, - }), -}); +/** + * Intersects embeddable-only props with panel-level schemas normally merged by the host + * (e.g. dashboard): serialized titles, time range, and drilldowns. + */ +function withPanelSchemas

( + embeddableSchema: ObjectType

, + allOfOptions?: { meta: typeof BY_VALUE_SCHEMA_META | typeof BY_REF_SCHEMA_META } +) { + return (getDrilldownsSchema: GetDrilldownsSchemaFnType) => + schema.allOf( + [ + serializedTitlesSchema, + serializedTimeRangeSchema, + getDrilldownsSchema(DISCOVER_SUPPORTED_DRILLDOWN_TRIGGERS), + embeddableSchema, + ], + allOfOptions ?? {} + ); +} -const discoverSessionByReferenceEmbeddableSchema = discoverSessionBaseEmbeddableSchema.extends({ - discover_session_id: schema.string(), - selected_tab_id: schema.maybe( - schema.string({ +const getDiscoverSessionByValueEmbeddableSchema = withPanelSchemas( + schema.object({ + tabs: schema.arrayOf(tabSchema, { + minSize: 1, + maxSize: 1, meta: { description: - 'The selected tab in the Discover session. If omitted, defaults to the first tab.', + 'Inline tab configuration. Used when no `discover_session_id` is set. Currently supports one tab.', }, - }) - ), -}); + }), + }), + { meta: BY_VALUE_SCHEMA_META } +); -export const discoverSessionEmbeddableSchema = schema.oneOf([ - discoverSessionByValueEmbeddableSchema, - discoverSessionByReferenceEmbeddableSchema, -]); +const getDiscoverSessionByReferenceEmbeddableSchema = withPanelSchemas( + schema.object({ + discover_session_id: schema.string(), + selected_tab_id: schema.maybe( + schema.string({ + meta: { + description: + 'Tab to select from the referenced saved object. If omitted, defaults to the first tab.', + }, + }) + ), + overrides: panelOverridesSchema, + }), + { meta: BY_REF_SCHEMA_META } +); + +export const getDiscoverSessionEmbeddableSchema = ( + getDrilldownsSchema: GetDrilldownsSchemaFnType +) => + schema.oneOf([ + getDiscoverSessionByValueEmbeddableSchema(getDrilldownsSchema), + getDiscoverSessionByReferenceEmbeddableSchema(getDrilldownsSchema), + ]); + +export type DiscoverSessionPanelOverrides = TypeOf; +export type DiscoverSessionClassicTab = TypeOf; +export type DiscoverSessionEsqlTab = TypeOf; +export type DiscoverSessionTab = TypeOf; export type DiscoverSessionEmbeddableByValueState = TypeOf< - typeof discoverSessionByValueEmbeddableSchema + ReturnType >; export type DiscoverSessionEmbeddableByReferenceState = TypeOf< - typeof discoverSessionByReferenceEmbeddableSchema + ReturnType +>; +export type DiscoverSessionEmbeddableState = TypeOf< + ReturnType >; -export type DiscoverSessionEmbeddableState = - | DiscoverSessionEmbeddableByValueState - | DiscoverSessionEmbeddableByReferenceState; diff --git a/src/platform/plugins/shared/discover/server/index.ts b/src/platform/plugins/shared/discover/server/index.ts index 93182d9d298b9..38793c79bdb04 100644 --- a/src/platform/plugins/shared/discover/server/index.ts +++ b/src/platform/plugins/shared/discover/server/index.ts @@ -40,6 +40,15 @@ export interface DiscoverServerPluginStart { } export { config } from './config'; +export type { + DiscoverSessionClassicTab, + DiscoverSessionEsqlTab, + DiscoverSessionTab, + DiscoverSessionPanelOverrides, + DiscoverSessionEmbeddableByValueState, + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableState, +} from './embeddable'; export const plugin = async (context: PluginInitializerContext) => { const { DiscoverServerPlugin } = await import('./plugin'); diff --git a/src/platform/plugins/shared/discover/server/plugin.ts b/src/platform/plugins/shared/discover/server/plugin.ts index 67767aa515222..5d915bc5a265f 100644 --- a/src/platform/plugins/shared/discover/server/plugin.ts +++ b/src/platform/plugins/shared/discover/server/plugin.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { Subscription } from 'rxjs'; import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; import type { PluginSetup as DataPluginSetup } from '@kbn/data-plugin/server'; import type { EmbeddableSetup } from '@kbn/embeddable-plugin/server'; @@ -15,6 +16,7 @@ import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/common'; import type { SharePluginSetup } from '@kbn/share-plugin/server'; import type { PluginInitializerContext } from '@kbn/core/server'; import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils'; +import { getDiscoverSessionEmbeddableSchema } from './embeddable/schema'; import type { DiscoverServerPluginStart, DiscoverServerPluginStartDeps } from '.'; import { DISCOVER_APP_LOCATOR } from '../common'; import { capabilitiesProvider } from './capabilities_provider'; @@ -25,6 +27,7 @@ import { getUiSettings } from './ui_settings'; import type { ConfigSchema } from './config'; import { appLocatorGetLocationCommon } from '../common/app_locator_get_location'; import { + EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, METRICS_EXPERIENCE_PRODUCT_FEATURE_ID, TRACES_PRODUCT_FEATURE_ID, } from '../common/constants'; @@ -34,6 +37,8 @@ export class DiscoverServerPlugin implements Plugin { private readonly config: ConfigSchema; + private subscriptions: Subscription[] = []; + private embeddableTransformsEnabled = false; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); @@ -66,7 +71,12 @@ export class DiscoverServerPlugin plugins.embeddable.registerEmbeddableFactory(createSearchEmbeddableFactory()); plugins.embeddable.registerTransforms(SEARCH_EMBEDDABLE_TYPE, { - getTransforms: getSearchEmbeddableTransforms, + getTransforms: (drilldownTransforms) => + getSearchEmbeddableTransforms(drilldownTransforms, () => this.embeddableTransformsEnabled), + getSchema: (getDrilldownsSchema) => + this.embeddableTransformsEnabled + ? getDiscoverSessionEmbeddableSchema(getDrilldownsSchema) + : undefined, }); core.pricing.registerProductFeatures([ @@ -89,8 +99,18 @@ export class DiscoverServerPlugin } public start(core: CoreStart, deps: DiscoverServerPluginStartDeps) { + this.subscriptions.push( + core.featureFlags + .getBooleanValue$(EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, this.embeddableTransformsEnabled) + .subscribe((value) => { + this.embeddableTransformsEnabled = value; + }) + ); + return { locator: initializeLocatorServices(core, deps) }; } - public stop() {} + public stop() { + this.subscriptions.forEach((sub) => sub.unsubscribe()); + } } diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index d1128855f2355..f49160fde0319 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -124,13 +124,17 @@ "@kbn/presentation-publishing-schemas", "@kbn/esql", "@kbn/controls-schemas", + "@kbn/as-code-data-views-schema", + "@kbn/as-code-data-views-transforms", "@kbn/as-code-filters-schema", + "@kbn/as-code-filters-transforms", "@kbn/scout", "@kbn/scout-synthtrace", "@kbn/synthtrace-client", "@kbn/alerting-v2-plugin", "@kbn/cps-utils", - "@kbn/as-code-data-views-schema", + "@kbn/core-saved-objects-common", + "@kbn/as-code-filters-constants", ], "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/saved_search/server/saved_objects/schema.ts b/src/platform/plugins/shared/saved_search/server/saved_objects/schema.ts index 99397d9e6be98..aed5fb2ab9728 100644 --- a/src/platform/plugins/shared/saved_search/server/saved_objects/schema.ts +++ b/src/platform/plugins/shared/saved_search/server/saved_objects/schema.ts @@ -9,6 +9,7 @@ import type { TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; +import { DataGridDensity } from '@kbn/discover-utils'; import { MIN_SAVED_SEARCH_SAMPLE_SIZE, MAX_SAVED_SEARCH_SAMPLE_SIZE, @@ -134,7 +135,11 @@ export const SCHEMA_SEARCH_MODEL_VERSION_4 = SCHEMA_SEARCH_MODEL_VERSION_3.exten export const SCHEMA_SEARCH_MODEL_VERSION_5 = SCHEMA_SEARCH_MODEL_VERSION_4.extends({ density: schema.maybe( - schema.oneOf([schema.literal('compact'), schema.literal('normal'), schema.literal('expanded')]) + schema.oneOf([ + schema.literal(DataGridDensity.COMPACT), + schema.literal(DataGridDensity.EXPANDED), + schema.literal(DataGridDensity.NORMAL), + ]) ), });