From 0afa3ac48768f69c2d6f4dbb1fd7593b5f2dbdd2 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Thu, 26 Feb 2026 16:56:34 -0700 Subject: [PATCH 01/33] =?UTF-8?q?Add=20SavedSearch=20=E2=86=94=20simplifie?= =?UTF-8?q?d=20API=20transforms=20for=20session=20embeddable=20(#248926)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discover/common/embeddable/constants.ts | 3 + .../common/embeddable/get_transform_in.ts | 71 +- .../common/embeddable/get_transform_out.ts | 63 +- .../discover/common/embeddable/index.ts | 5 + .../search_embeddable_transforms.test.ts | 281 ++++---- .../search_embeddable_transforms.ts | 5 +- .../common/embeddable/transform_utils.test.ts | 645 ++++++++++++++++++ .../common/embeddable/transform_utils.ts | 324 +++++++++ .../plugins/shared/discover/common/index.ts | 6 + .../get_search_embeddable_factory.tsx | 12 +- .../discover/public/embeddable/types.ts | 13 +- .../utils/serialization_utils.test.ts | 231 ++++--- .../embeddable/utils/serialization_utils.ts | 44 +- .../discover/server/embeddable/index.ts | 11 + .../discover/server/embeddable/schema.ts | 44 +- .../plugins/shared/discover/server/index.ts | 11 + .../server/saved_objects/schema.ts | 7 +- 17 files changed, 1405 insertions(+), 371 deletions(-) create mode 100644 src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts create mode 100644 src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts diff --git a/src/platform/plugins/shared/discover/common/embeddable/constants.ts b/src/platform/plugins/shared/discover/common/embeddable/constants.ts index 97937f61b5735..94f2cd2a10b2a 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/constants.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/constants.ts @@ -9,6 +9,9 @@ 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'; + /** 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 ff66dce2fd9b9..078a9177eb33d 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,23 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { omit } from 'lodash'; -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 type { - SearchEmbeddableByReferenceState, - SearchEmbeddableState, - StoredSearchEmbeddableState, -} from './types'; +import { discoverSessionToSavedSearchEmbeddableState } from './transform_utils'; +import type { DiscoverSessionEmbeddableState } from '../../server'; +import type { StoredSearchEmbeddableState } from './types'; -export const SAVED_SEARCH_SAVED_OBJECT_REF_NAME = 'savedObjectRef'; - -function isByRefState(state: SearchEmbeddableState): state is SearchEmbeddableByReferenceState { - return 'savedObjectId' in state; -} +export { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; export function getTransformIn(transformDrilldownsIn: DrilldownTransforms['transformIn']) { - function transformIn(state: SearchEmbeddableState): { + function transformIn(state: DiscoverSessionEmbeddableState): { state: StoredSearchEmbeddableState; references: SavedObjectReference[]; } { const { state: storedState, references: drilldownReferences } = - transformDrilldownsIn(state); - - if (isByRefState(storedState)) { - const { savedObjectId, ...rest } = storedState; - return { - state: rest, - references: [ - { - name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, - type: SavedSearchType, - id: savedObjectId, - }, - ...drilldownReferences, - ], - }; - } - - // 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; - } - }); + transformDrilldownsIn(state); - return { - state: { - ...storedState, - attributes: { - ...omit(storedState.attributes, 'references'), - tabs, - }, - }, - references: [...tabReferences, ...drilldownReferences], - }; + return discoverSessionToSavedSearchEmbeddableState(storedState, drilldownReferences); } return transformIn; } 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 96f94d5f94dc1..43d52f75c6e3d 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,28 +7,12 @@ * 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 { injectReferences, parseSearchSourceJSON } from '@kbn/data-plugin/common'; +import { flow } from 'lodash'; import type { DrilldownTransforms } from '@kbn/embeddable-plugin/common'; import type { SavedObjectReference } from '@kbn/core/server'; import { transformTitlesOut } from '@kbn/presentation-publishing'; -import type { - SearchEmbeddableByReferenceState, - SearchEmbeddableByValueState, - StoredSearchEmbeddableByValueState, - 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 { savedSearchToDiscoverSessionEmbeddableState } from './transform_utils'; +import type { StoredSearchEmbeddableState } from './types'; export function getTransformOut(transformDrilldownsOut: DrilldownTransforms['transformOut']) { function transformOut( @@ -40,46 +24,7 @@ export function getTransformOut(transformDrilldownsOut: DrilldownTransforms['tra (state: StoredSearchEmbeddableState) => transformDrilldownsOut(state, references) ); const state = transformsFlow(storedState); - - 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), - }, - }, - }; - } catch (e) { - return tab; - } - }); - - return { - ...state, - attributes: { - ...state.attributes, - tabs, - }, - } as SearchEmbeddableByValueState; - } - - 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; + return savedSearchToDiscoverSessionEmbeddableState(state, references); } return transformOut; } diff --git a/src/platform/plugins/shared/discover/common/embeddable/index.ts b/src/platform/plugins/shared/discover/common/embeddable/index.ts index b383982938a5c..dc1ed713428d6 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/index.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/index.ts @@ -8,3 +8,8 @@ */ export { getSearchEmbeddableTransforms } from './search_embeddable_transforms'; +export { + discoverSessionToSavedSearchEmbeddableState, + isByReferenceDiscoverSessionEmbeddableState, + isByReferenceSavedSearchEmbeddableState, +} 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 78140e4f0bca6..e02cfaf4f1a6c 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 @@ -9,16 +9,20 @@ import type { DrilldownTransforms } from '@kbn/embeddable-plugin/common'; import { getSearchEmbeddableTransforms } from './search_embeddable_transforms'; +import type { StoredSearchEmbeddableByValueState, StoredSearchEmbeddableState } from './types'; import type { - SearchEmbeddableByValueState, - StoredSearchEmbeddableByValueState, - StoredSearchEmbeddableState, - SearchEmbeddableByReferenceState, - SearchEmbeddableState, -} from './types'; + 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,204 +33,213 @@ describe('searchEmbeddableTransforms', () => { beforeEach(() => { jest.clearAllMocks(); }); + 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', + timeRange: { 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 = { - 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, + title: 'Test Title', + description: 'Test Description', + timeRange: { from: 'now-15m', to: 'now' }, + discover_session_id: 'session-123', + selected_tab_id: undefined, }); + 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, + 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, + 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).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].columns).toEqual([ + { name: 'message' }, + { name: '@timestamp', width: 200 }, + ]); + expect(result.tabs[0].sort).toEqual([{ name: '@timestamp', direction: 'desc' }]); + expect(result.tabs[0].view_mode).toBe(VIEW_MODE.DOCUMENT_LEVEL); + expect(result.tabs[0].density).toBe(DataGridDensity.COMPACT); + expect((result.tabs[0] as DiscoverSessionClassicTab).dataset).toEqual({ + type: 'dataView', + 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 mockReferences = [ + { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-xyz' }, + ]; const result = getSearchEmbeddableTransforms(mockDrilldownTransforms).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']], + timeRange: { from: 'now-15m', to: 'now' }, + discover_session_id: 'test-saved-object-id', + selected_tab_id: undefined, }; const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(serializedState); + getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(apiState); expect(result.state).toEqual({ title: 'Test Search', description: 'Test Description', - columns: ['field1', 'field2'], - sort: [['timestamp', 'desc']], - drilldowns: [], + timeRange: { 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', + timeRange: { from: 'now-1h', to: 'now' }, + discover_session_id: 'session-456', + selected_tab_id: 'tab-1', }; const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(serializedState); + getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(apiState); expect(result.state).toEqual({ - title: 'Test Search', - columns: ['field1'], + title: 'My Search', + description: 'My description', + timeRange: { from: 'now-1h', to: 'now' }, }); - 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, - 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, - isTextBasedQuery: false, - kibanaSavedObjectMeta: { - searchSourceJSON: '{}', + description: 'Panel description', + tabs: [ + { + columns: [{ name: 'message' }, { name: '@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, + dataset: { type: 'dataView', id: 'data-view-1' }, }, - tabs: [], - references: [], - }, - drilldowns: [], + ], }; const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(serializedState); + getSearchEmbeddableTransforms(mockDrilldownTransforms).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); }); }); }); 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..474caa941121f 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,13 +8,14 @@ */ import type { DrilldownTransforms, EmbeddableTransforms } from '@kbn/embeddable-plugin/common'; -import type { SearchEmbeddableState, StoredSearchEmbeddableState } from './types'; +import type { DiscoverSessionEmbeddableState } from '../../server'; +import type { StoredSearchEmbeddableState } from './types'; import { getTransformIn } from './get_transform_in'; import { getTransformOut } from './get_transform_out'; export function getSearchEmbeddableTransforms( drilldownTransforms: DrilldownTransforms -): EmbeddableTransforms { +): EmbeddableTransforms { return { transformIn: getTransformIn(drilldownTransforms.transformIn), transformOut: getTransformOut(drilldownTransforms.transformOut), 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..d53034b0c4c83 --- /dev/null +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -0,0 +1,645 @@ +/* + * 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 { SavedObjectReference } from '@kbn/core-saved-objects-common/src/server_types'; +import { + byReferenceDiscoverSessionToSavedSearchEmbeddableState, + byReferenceSavedSearchToDiscoverSessionEmbeddableState, + byValueDiscoverSessionToSavedSearchEmbeddableState, + byValueSavedSearchToDiscoverSessionEmbeddableState, + fromStoredColumns, + fromStoredDataset, + fromStoredRuntimeFields, + fromStoredSort, + toStoredColumns, + toStoredDataset, + toStoredGrid, + toStoredRuntimeFields, + toStoredSort, +} from './transform_utils'; +import type { + StoredSearchEmbeddableByReferenceState, + StoredSearchEmbeddableByValueState, +} from './types'; +import { SavedSearchType } from '@kbn/saved-search-plugin/common'; +import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; +import type { + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableByValueState, +} from '../../server'; +import { DataGridDensity } from '@kbn/discover-utils'; +import type { + DiscoverSessionDataViewReference, + DiscoverSessionDataViewSpec, +} from '../../server/embeddable'; +import type { DataViewSpec } from '@kbn/data-views-plugin/common'; +import { ASCODE_FILTER_OPERATOR, ASCODE_FILTER_TYPE } from '@kbn/as-code-filters-constants'; + +describe('search embeddable transform utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('byValueSavedSearchToDiscoverSessionEmbeddableState', () => { + it('converts to DiscoverSessionEmbeddableByValueState', () => { + 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, + 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, + viewMode: VIEW_MODE.DOCUMENT_LEVEL, + isTextBasedQuery: false, + timeRestore: false, + }, + }, + ], + }, + }; + + const expected: DiscoverSessionEmbeddableByValueState = { + title: '[filebeat-*] elasticsearch logs', + description: 'my description', + // type: 'search', + 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' }], + columns: [{ name: 'message' }], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 'auto', + row_height: 'auto', + dataset: { + type: 'dataView', + id: 'c7d7a1f5-19da-4ba9-af15-5919e8cd2528', + }, + }, + ], + }; + + const result = byValueSavedSearchToDiscoverSessionEmbeddableState(storedState); + + expect(result).toEqual(expected); + }); + }); + + describe('byReferenceSavedSearchToDiscoverSessionEmbeddableState', () => { + it('converts stored by-reference state to discover session embeddable state with references', () => { + const storedSearch: StoredSearchEmbeddableByReferenceState = { + title: 'My Saved Search', + description: 'My description', + timeRange: { from: 'now-15m', to: 'now' }, + }; + const references: SavedObjectReference[] = [ + { name: 'savedObjectRef', type: SavedSearchType, id: 'session-123' }, + ]; + const result = byReferenceSavedSearchToDiscoverSessionEmbeddableState( + storedSearch, + references + ); + expect(result).toEqual({ + title: 'My Saved Search', + description: 'My description', + timeRange: { from: 'now-15m', to: 'now' }, + discover_session_id: 'session-123', + selected_tab_id: undefined, + }); + }); + }); + + describe('byReferenceDiscoverSessionToSavedSearchEmbeddableState', () => { + it('converts discover session by-reference state to stored state with references', () => { + const apiState: DiscoverSessionEmbeddableByReferenceState = { + title: 'My Search', + description: 'My description', + timeRange: { from: 'now-15m', to: 'now' }, + discover_session_id: 'session-456', + selected_tab_id: 'tab-1', + }; + const result = byReferenceDiscoverSessionToSavedSearchEmbeddableState(apiState); + expect(result.references).toEqual([ + { + name: 'savedObjectRef', + type: SavedSearchType, + id: 'session-456', + }, + ]); + expect(result.state).toEqual({ + title: 'My Search', + description: 'My description', + timeRange: { from: 'now-15m', to: 'now' }, + }); + }); + }); + + describe('byValueDiscoverSessionToSavedSearchEmbeddableState', () => { + it('converts discover session by-value state to stored state with references', () => { + const apiState: DiscoverSessionEmbeddableByValueState = { + title: 'Panel Title', + description: 'Panel description', + timeRange: { from: 'now-1h', to: 'now' }, + tabs: [ + { + columns: [{ name: 'message' }, { name: '@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, + dataset: { type: 'dataView', id: 'data-view-1' }, + }, + ], + }; + const result = byValueDiscoverSessionToSavedSearchEmbeddableState(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].attributes.columns).toEqual(['message', '@timestamp']); + expect(result.state.attributes.tabs[0].attributes.sort).toEqual([['@timestamp', 'desc']]); + expect(result.state.attributes.tabs[0].attributes.viewMode).toBe(VIEW_MODE.DOCUMENT_LEVEL); + expect(result.state.attributes.tabs[0].attributes.rowHeight).toBe(-1); + expect(result.state.attributes.tabs[0].attributes.headerRowHeight).toBe(-1); + expect( + JSON.parse( + result.state.attributes.tabs[0].attributes.kibanaSavedObjectMeta.searchSourceJSON + ) + ).toEqual({ + index: 'data-view-1', + query: { language: 'kuery', query: '' }, + filter: [], + }); + }); + + it('converts index-pattern tab with runtime fields to stored state', () => { + const apiState: DiscoverSessionEmbeddableByValueState = { + title: 'Adhoc', + timeRange: { from: 'now-1h', to: 'now' }, + tabs: [ + { + columns: [{ name: '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, + dataset: { + type: 'index', + index: 'my-*', + time_field: '@timestamp', + runtime_fields: [ + { + name: 'rt', + type: 'keyword', + script: 'emit("x")', + format: { id: 'string' }, + }, + ], + }, + }, + ], + }; + const result = byValueDiscoverSessionToSavedSearchEmbeddableState(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' }, + }, + runtimeFieldMap: { + rt: { + type: 'keyword', + script: { source: 'emit("x")' }, + }, + }, + }); + }); + }); + + describe('fromStoredColumns', () => { + it('maps column names to column objects without width when grid has no column widths', () => { + const columns = ['message', '@timestamp']; + const grid = { columns: {} }; + const result = fromStoredColumns(columns, grid); + expect(result).toEqual([{ name: 'message' }, { name: '@timestamp' }]); + }); + + it('includes width from grid when present', () => { + const columns = ['message', '@timestamp']; + const grid = { + columns: { + message: { width: 100 }, + '@timestamp': { width: 200 }, + }, + }; + const result = fromStoredColumns(columns, grid); + expect(result).toEqual([ + { name: 'message', width: 100 }, + { name: '@timestamp', width: 200 }, + ]); + }); + + it('includes width only for columns that have it in grid', () => { + const columns = ['message', '@timestamp', 'source']; + const grid = { + columns: { + '@timestamp': { width: 150 }, + }, + }; + const result = fromStoredColumns(columns, grid); + expect(result).toEqual([ + { name: 'message' }, + { name: '@timestamp', width: 150 }, + { name: 'source' }, + ]); + }); + }); + + describe('toStoredColumns', () => { + it('maps column objects to column names', () => { + const columns = [{ name: 'message' }, { name: '@timestamp', width: 200 }]; + const result = toStoredColumns(columns); + expect(result).toEqual(['message', '@timestamp']); + }); + + it('returns empty array when columns is empty', () => { + expect(toStoredColumns([])).toEqual([]); + }); + + it('returns empty array when columns is undefined (default)', () => { + expect(toStoredColumns()).toEqual([]); + }); + }); + + describe('toStoredGrid', () => { + it('builds grid from columns (only columns with width are included)', () => { + const columns = [{ name: 'message' }, { name: '@timestamp', width: 200 }]; + const result = toStoredGrid(columns); + expect(result).toEqual({ + columns: { + '@timestamp': { width: 200 }, + }, + }); + }); + + it('returns empty columns object when columns is empty', () => { + expect(toStoredGrid([])).toEqual({ columns: {} }); + }); + + it('returns empty columns object when columns is undefined (default)', () => { + expect(toStoredGrid()).toEqual({ columns: {} }); + }); + }); + + 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('fromStoredDataset', () => { + it('throws when index is null', () => { + expect(() => fromStoredDataset(null as unknown as string)).toThrow( + 'Data view is required to convert from stored dataset' + ); + }); + + it('returns dataView reference when index is a string id', () => { + const result = fromStoredDataset('my-data-view-id'); + expect(result).toEqual({ type: 'dataView', id: 'my-data-view-id' }); + }); + + it('throws when index object has no title or id', () => { + expect(() => fromStoredDataset({ timeFieldName: '@timestamp' } as unknown as string)).toThrow( + 'Stored index object must have a title or id to convert to dataset' + ); + }); + + it('transforms index-pattern object to DiscoverSessionDataViewSpec', () => { + const index: DataViewSpec = { + id: 'eaa3802b-a071-49c0-8442-1fcd2cdcc9fa', + title: 'f*', + timeFieldName: '@timestamp', + sourceFilters: [], + fieldFormats: { + foobar: { + id: 'url', + params: { + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/app/dashboards', + basePath: '', + }, + type: 'a', + urlTemplate: 'http://google.com?q={{value}}', + labelTemplate: 'google search for {{value}}', + width: null, + height: null, + }, + }, + }, + runtimeFieldMap: { + foobar: { + type: 'keyword', + script: { + source: 'emit(UUID.randomUUID().toString())', + }, + }, + }, + fieldAttrs: { + foobar: { + customLabel: 'my custom label', + customDescription: 'my custom description', + }, + }, + allowNoIndex: false, + name: 'f*', + allowHidden: false, + managed: false, + }; + const expected: DiscoverSessionDataViewSpec = { + type: 'index', + index: 'f*', + time_field: '@timestamp', + runtime_fields: [ + { + type: 'keyword', + name: 'foobar', + script: 'emit(UUID.randomUUID().toString())', + format: { + id: 'url', + params: { + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/app/dashboards', + basePath: '', + }, + type: 'a', + urlTemplate: 'http://google.com?q={{value}}', + labelTemplate: 'google search for {{value}}', + width: null, + height: null, + }, + }, + }, + ], + }; + const result = fromStoredDataset(index); + expect(result).toEqual(expected); + }); + }); + + describe('fromStoredRuntimeFields', () => { + it('transforms runtime fields with field formats', () => { + const runtimeFieldMap: DataViewSpec['runtimeFieldMap'] = { + foobar: { + type: 'keyword', + script: { + source: 'emit(UUID.randomUUID().toString())', + }, + }, + }; + const fieldFormats: DataViewSpec['fieldFormats'] = { + foobar: { + id: 'url', + params: { + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/app/dashboards', + basePath: '', + }, + type: 'a', + urlTemplate: 'http://google.com?q={{value}}', + labelTemplate: 'google search for {{value}}', + width: null, + height: null, + }, + }, + }; + const expected: DiscoverSessionDataViewSpec['runtime_fields'] = [ + { + type: 'keyword', + name: 'foobar', + script: 'emit(UUID.randomUUID().toString())', + format: { + id: 'url', + params: { + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/app/dashboards', + basePath: '', + }, + type: 'a', + urlTemplate: 'http://google.com?q={{value}}', + labelTemplate: 'google search for {{value}}', + width: null, + height: null, + }, + }, + }, + ]; + const result = fromStoredRuntimeFields(runtimeFieldMap, fieldFormats); + expect(result).toEqual(expected); + }); + }); + + describe('toStoredDataset', () => { + it('converts dataView dataset to string id', () => { + const dataset: DiscoverSessionDataViewReference = { + type: 'dataView', + id: 'my-data-view-id', + }; + const result = toStoredDataset(dataset); + expect(result).toBe('my-data-view-id'); + }); + + it('converts index-pattern dataset to serialized index spec', () => { + const dataset: DiscoverSessionDataViewSpec = { + type: 'index', + index: 'my-index-*', + time_field: '@timestamp', + runtime_fields: [ + { + name: 'rt', + type: 'keyword', + script: 'emit(doc["id"].value)', + format: { id: 'string' }, + }, + ], + }; + const result = toStoredDataset(dataset); + expect(result).toEqual({ + title: 'my-index-*', + timeFieldName: '@timestamp', + fieldFormats: { + rt: { id: 'string' }, + }, + runtimeFieldMap: { + rt: { + type: 'keyword', + script: { source: 'emit(doc["id"].value)' }, + }, + }, + }); + }); + + it('converts index-pattern dataset without runtime fields', () => { + const dataset: DiscoverSessionDataViewSpec = { + type: 'index', + index: 'logs-*', + time_field: '@timestamp', + }; + const result = toStoredDataset(dataset); + expect(result).toEqual({ + title: 'logs-*', + timeFieldName: '@timestamp', + }); + }); + }); + + describe('toStoredRuntimeFields', () => { + it('converts runtime fields to DataViewSpec runtimeFieldMap', () => { + const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ + { + name: 'myField', + type: 'keyword', + script: 'emit("hello")', + format: { id: 'url', params: {} }, + }, + ]; + const result = toStoredRuntimeFields(runtimeFields); + expect(result).toEqual({ + myField: { + type: 'keyword', + script: { source: 'emit("hello")' }, + }, + }); + }); + + it('omits script when not present', () => { + const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ + { name: 'f', type: 'long' }, + ]; + const result = toStoredRuntimeFields(runtimeFields); + expect(result).toEqual({ + f: { type: 'long' }, + }); + }); + + it('omits fieldFormat when not present', () => { + const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ + { name: 'f', type: 'keyword', script: 'emit("x")' }, + ]; + const result = toStoredRuntimeFields(runtimeFields); + expect(result).toEqual({ + f: { type: 'keyword', script: { source: 'emit("x")' } }, + }); + }); + + it('returns empty object when runtimeFields is undefined (default)', () => { + expect(toStoredRuntimeFields()).toEqual({}); + }); + + it('returns empty object when runtimeFields is empty array', () => { + expect(toStoredRuntimeFields([])).toEqual({}); + }); + }); +}); 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..66a4a8de93109 --- /dev/null +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -0,0 +1,324 @@ +/* + * 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 { + extractTabs, + type SavedSearchByValueAttributes, + SavedSearchType, + VIEW_MODE, +} from '@kbn/saved-search-plugin/common'; +import { DataGridDensity } from '@kbn/discover-utils'; +import type { DataViewSpec, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { extractReferences } from '@kbn/data-plugin/common'; +import { injectReferences, parseSearchSourceJSON } from '@kbn/data-plugin/common'; +import { fromStoredFilters, toStoredFilters } from '@kbn/as-code-filters-transforms'; +import { isOfAggregateQueryType } from '@kbn/es-query'; +import type { SavedObjectReference } from '@kbn/core-saved-objects-common/src/server_types'; +import type { + DiscoverSessionClassicTab, + DiscoverSessionDataset, + DiscoverSessionDataViewSpec, + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableByValueState, + DiscoverSessionEmbeddableState, + DiscoverSessionTab, +} from '../../server'; +import type { + StoredSearchEmbeddableByReferenceState, + StoredSearchEmbeddableByValueState, + StoredSearchEmbeddableState, +} from './types'; +import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; + +export function isByReferenceSavedSearchEmbeddableState( + state: StoredSearchEmbeddableState +): state is StoredSearchEmbeddableByReferenceState { + return !('attributes' in state); +} + +export function isByReferenceDiscoverSessionEmbeddableState( + state: DiscoverSessionEmbeddableState +): state is DiscoverSessionEmbeddableByReferenceState { + return 'discover_session_id' in state; +} + +export function savedSearchToDiscoverSessionEmbeddableState( + storedSearch: StoredSearchEmbeddableState, + references: SavedObjectReference[] = [] +): DiscoverSessionEmbeddableState { + return isByReferenceSavedSearchEmbeddableState(storedSearch) + ? byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedSearch, references) + : byValueSavedSearchToDiscoverSessionEmbeddableState(storedSearch, references); +} + +export function discoverSessionToSavedSearchEmbeddableState( + apiState: DiscoverSessionEmbeddableState, + references: SavedObjectReference[] = [] +): { state: StoredSearchEmbeddableState; references: SavedObjectReference[] } { + return isByReferenceDiscoverSessionEmbeddableState(apiState) + ? byReferenceDiscoverSessionToSavedSearchEmbeddableState(apiState, references) + : byValueDiscoverSessionToSavedSearchEmbeddableState(apiState, references); +} + +export function byReferenceSavedSearchToDiscoverSessionEmbeddableState( + storedSearch: StoredSearchEmbeddableByReferenceState, + references: SavedObjectReference[] = [] +): DiscoverSessionEmbeddableByReferenceState { + const { title, description, timeRange } = storedSearch; + 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 { + title, + description, + timeRange, + discover_session_id: savedObjectRef.id, + selected_tab_id: undefined, // Waiting on https://github.com/elastic/kibana/pull/252311 + }; +} + +export function byReferenceDiscoverSessionToSavedSearchEmbeddableState( + 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, timeRange, ...state } = apiState; + return { state: { ...state, timeRange }, references: [discoverSessionReference, ...references] }; +} + +export function byValueSavedSearchToDiscoverSessionEmbeddableState( + storedSearch: StoredSearchEmbeddableByValueState, + references: SavedObjectReference[] = [] +): DiscoverSessionEmbeddableByValueState { + const { title, description, timeRange } = storedSearch; + const [tab] = storedSearch.attributes.tabs ?? extractTabs(storedSearch.attributes).tabs; + const { + columns, + grid, + sort, + rowHeight, + headerRowHeight, + rowsPerPage, + sampleSize, + viewMode, + density, + kibanaSavedObjectMeta: { searchSourceJSON }, + } = tab.attributes; + + const sharedAttrs = { + columns: fromStoredColumns(columns, grid), + sort: fromStoredSort(sort), + view_mode: viewMode ?? VIEW_MODE.DOCUMENT_LEVEL, + density: density ?? DataGridDensity.COMPACT, + header_row_height: (headerRowHeight === undefined || headerRowHeight === -1 + ? 'auto' + : headerRowHeight) as DiscoverSessionTab['header_row_height'], + row_height: (rowHeight === undefined || rowHeight === -1 + ? 'auto' + : rowHeight) as DiscoverSessionTab['row_height'], + }; + + const searchSourceValues = parseSearchSourceJSON(searchSourceJSON); + const searchSourceFields = injectReferences(searchSourceValues, references); + const { index, query, filter } = searchSourceFields; + + const newTab: DiscoverSessionTab = isOfAggregateQueryType(query) + ? { + ...sharedAttrs, + query, + } + : { + ...sharedAttrs, + query, + filters: fromStoredFilters(filter) ?? [], + rows_per_page: rowsPerPage as DiscoverSessionClassicTab['rows_per_page'], + sample_size: sampleSize, + dataset: fromStoredDataset(index), + }; + + return { + title, + description, + timeRange, + tabs: [newTab], + }; +} + +export function byValueDiscoverSessionToSavedSearchEmbeddableState( + apiState: DiscoverSessionEmbeddableByValueState, + references: SavedObjectReference[] = [] +): { state: StoredSearchEmbeddableByValueState; references: SavedObjectReference[] } { + if (!apiState.tabs?.length) { + throw new Error('Discover session by-value state must have at least one tab'); + } + const { + tabs: [tab], + ...state + } = apiState; + + const searchSourceValues = { + index: 'dataset' in tab ? toStoredDataset(tab.dataset) : undefined, + query: tab.query, + filter: 'filters' in tab ? toStoredFilters(tab.filters) : undefined, + }; + const [, searchSourceReferences] = extractReferences(searchSourceValues); + + const sharedAttrs: DiscoverSessionTabAttributes = { + sort: toStoredSort(tab.sort), + columns: toStoredColumns(tab.columns), + grid: toStoredGrid(tab.columns), + hideChart: false, + isTextBasedQuery: !('dataset' in tab), + viewMode: tab.view_mode, + rowHeight: tab.row_height === 'auto' || tab.row_height === undefined ? -1 : tab.row_height, + headerRowHeight: + tab.header_row_height === 'auto' || tab.header_row_height === undefined + ? -1 + : tab.header_row_height, + density: tab.density, + ...('sample_size' in tab && { sampleSize: tab.sample_size }), + ...('rows_per_page' in tab && { rowsPerPage: tab.rows_per_page }), + kibanaSavedObjectMeta: { searchSourceJSON: JSON.stringify(searchSourceValues) }, + }; + const attributes: SavedSearchByValueAttributes = { + title: apiState.title ?? '', + description: apiState.description ?? '', + ...sharedAttrs, + sort: sharedAttrs.sort as SavedSearchByValueAttributes['sort'], + columns: sharedAttrs.columns as SavedSearchByValueAttributes['columns'], + tabs: [ + { + id: '', // Unused for byValue but required for schema validation + label: '', // Unused for byValue but required for schema validation + attributes: sharedAttrs, + }, + ], + }; + return { + state: { ...state, attributes }, + references: [...references, ...searchSourceReferences], + }; +} + +export function fromStoredColumns( + columns: DiscoverSessionTabAttributes['columns'], + grid: DiscoverSessionTabAttributes['grid'] +): DiscoverSessionTab['columns'] { + return columns.map((name) => ({ + name, + ...(grid.columns?.[name] && { width: grid.columns?.[name]?.width }), + })); +} + +export function toStoredColumns( + columns: DiscoverSessionTab['columns'] = [] +): DiscoverSessionTabAttributes['columns'] { + return columns.map((c) => c.name); +} + +export function toStoredGrid( + columns: DiscoverSessionTab['columns'] = [] +): DiscoverSessionTabAttributes['grid'] { + const entries = columns + ?.filter((c) => c.width != null) // Only persist columns with a width defined + .map(({ name, width }) => [name, { width }]); + return { columns: Object.fromEntries(entries) }; +} + +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'] { + return sort.map((s) => [s.name, s.direction]); +} + +export function fromStoredDataset( + index: SerializedSearchSourceFields['index'] +): DiscoverSessionDataset { + if (index == null) throw new Error('Data view is required to convert from stored dataset'); + if (typeof index === 'string') return { type: 'dataView', id: index }; + const title = index.title ?? index.id; + if (title == null || title === '') { + throw new Error('Stored index object must have a title or id to convert to dataset'); + } + return { + type: 'index', + index: title, + time_field: index.timeFieldName, + runtime_fields: fromStoredRuntimeFields(index.runtimeFieldMap, index.fieldFormats), + }; +} + +export function toStoredDataset( + dataset: DiscoverSessionDataset +): SerializedSearchSourceFields['index'] { + if (dataset.type === 'dataView') return dataset.id; + const runtimeFieldMap = toStoredRuntimeFields(dataset.runtime_fields); + const fieldFormats = toStoredFieldFormats(dataset.runtime_fields); + return { + title: dataset.index, + timeFieldName: dataset.time_field, + ...(runtimeFieldMap && Object.keys(runtimeFieldMap).length > 0 && { runtimeFieldMap }), + ...(fieldFormats && Object.keys(fieldFormats).length > 0 && { fieldFormats }), + }; +} + +export function fromStoredRuntimeFields( + runtimeFields: DataViewSpec['runtimeFieldMap'] = {}, + fieldFormats: DataViewSpec['fieldFormats'] = {} +): DiscoverSessionDataViewSpec['runtime_fields'] { + return Object.keys(runtimeFields).map((name) => ({ + type: runtimeFields?.[name].type, + name, + script: runtimeFields?.[name].script?.source, + format: fieldFormats?.[name], + })); +} + +export function toStoredRuntimeFields( + runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [] +): DataViewSpec['runtimeFieldMap'] { + if (!runtimeFields || runtimeFields.length === 0) return {}; + return runtimeFields.reduce((acc, { name, type, script }) => { + return { + ...acc, + [name]: { + type, + ...(script && { script: { source: script } }), + }, + }; + }, {}); +} + +export function toStoredFieldFormats( + runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [] +): DataViewSpec['fieldFormats'] { + if (!runtimeFields || runtimeFields.length === 0) return undefined; + return runtimeFields.reduce((acc, { name, format }) => { + return { + ...acc, + ...(format ? { [name]: format } : {}), + }; + }, {}); +} diff --git a/src/platform/plugins/shared/discover/common/index.ts b/src/platform/plugins/shared/discover/common/index.ts index f712244098e44..3959e9effb421 100644 --- a/src/platform/plugins/shared/discover/common/index.ts +++ b/src/platform/plugins/shared/discover/common/index.ts @@ -21,3 +21,9 @@ export type { export type { DiscoverESQLLocator, DiscoverESQLLocatorParams } from './esql_locator'; export type { NonPersistedDisplayOptions, SearchEmbeddableState } from './embeddable/types'; + +export { + discoverSessionToSavedSearchEmbeddableState, + isByReferenceDiscoverSessionEmbeddableState, + isByReferenceSavedSearchEmbeddableState, +} from './embeddable'; 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 ab4b49e3fb466..f64dca1792a93 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 @@ -37,7 +37,7 @@ import { SearchEmbeddableGridComponent } from './components/search_embeddable_gr import { initializeEditApi } from './initialize_edit_api'; import { initializeFetch, isEsqlMode } from './initialize_fetch'; import { initializeSearchEmbeddableApi } from './initialize_search_embeddable_api'; -import type { SearchEmbeddableState } from '../../common/embeddable/types'; +import type { DiscoverSessionEmbeddableState } from '../../server'; import type { SearchEmbeddableApi } from './types'; import { deserializeState, serializeState } from './utils/serialization_utils'; import { BaseAppWrapper } from '../context_awareness'; @@ -57,7 +57,7 @@ export const getSearchEmbeddableFactory = ({ const { save, checkForDuplicateTitle } = discoverServices.savedSearch; const savedSearchEmbeddableFactory: EmbeddableFactory< - SearchEmbeddableState, + DiscoverSessionEmbeddableState, SearchEmbeddableApi > = { type: SEARCH_EMBEDDABLE_TYPE, @@ -100,9 +100,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(runtimeState, { discoverServices, }); @@ -118,7 +118,7 @@ export const getSearchEmbeddableFactory = ({ savedObjectId, }); - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ uuid, parentApi, serializeState: () => serialize(savedObjectId$.getValue()), diff --git a/src/platform/plugins/shared/discover/public/embeddable/types.ts b/src/platform/plugins/shared/discover/public/embeddable/types.ts index 9604ce7860979..45b81c1626702 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/types.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/types.ts @@ -31,12 +31,21 @@ import type { DataTableColumnsMeta } from '@kbn/unified-data-table'; import type { BehaviorSubject } from 'rxjs'; import type { PublishesWritableDataViews } from '@kbn/presentation-publishing/interfaces/publishes_data_views'; import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; +import type { DiscoverSessionEmbeddableState } from '../../server'; import type { EditableSavedSearchAttributes, NonPersistedDisplayOptions, - SearchEmbeddableState, } from '../../common/embeddable/types'; +/** + * Input state accepted by the search embeddable factory. Extends the persisted + * session state with optional display options passed by solutions (e.g. APM, Infra) + * when using SavedSearchComponent outside of dashboards. These options are not persisted. + */ +export type SearchEmbeddableInputState = DiscoverSessionEmbeddableState & { + nonPersistedDisplayOptions?: NonPersistedDisplayOptions; +}; + export type SearchEmbeddablePublicState = Pick< SerializableSavedSearch, | 'rowHeight' @@ -78,7 +87,7 @@ export type SearchEmbeddableRuntimeState = SearchEmbeddableSerializedAttributes nonPersistedDisplayOptions?: NonPersistedDisplayOptions; }; -export type SearchEmbeddableApi = DefaultEmbeddableApi & +export type SearchEmbeddableApi = DefaultEmbeddableApi & PublishesSavedObjectId & PublishesDataLoading & PublishesBlockingError & 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 f5ba30a5099f2..8f04fd1d79603 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 @@ -10,73 +10,67 @@ import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; -import { toSavedSearchAttributes } from '@kbn/saved-search-plugin/common'; import { discoverServiceMock } from '../../__mocks__/services'; -import type { - SearchEmbeddableByValueState, - SearchEmbeddableState, -} from '../../../common/embeddable/types'; import { deserializeState, serializeState } from './serialization_utils'; -import type { DiscoverSessionTab } from '@kbn/saved-search-plugin/server'; +import type { + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableByValueState, +} from '../../../server'; +import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; +import { DataGridDensity } from '@kbn/discover-utils'; describe('Serialization utils', () => { const uuid = 'mySearchEmbeddable'; - const tabs: DiscoverSessionTab[] = [ - { - id: 'tab-1', - label: 'Tab 1', - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: '{"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', - }, - sort: [['order_date', 'desc']], - columns: ['_source'], - grid: {}, - hideChart: false, - sampleSize: 100, - isTextBasedQuery: false, - }, - }, - ]; - const mockedSavedSearchAttributes: SearchEmbeddableByValueState['attributes'] = { - kibanaSavedObjectMeta: { - searchSourceJSON: '{"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', - }, - title: 'test1', - sort: [['order_date', 'desc']], - columns: ['_source'], + const dataViewId = dataViewMock.id ?? 'test-id'; + + /** Minimal API shape for by-value (DiscoverSessionEmbeddableByValueState) */ + const apiStateByValue: DiscoverSessionEmbeddableByValueState = { + title: 'test panel title', description: 'description', - grid: {}, - hideChart: false, - sampleSize: 100, - isTextBasedQuery: false, - tabs, - references: [ + tabs: [ { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - id: dataViewMock.id ?? 'test-id', - type: 'index-pattern', + columns: [{ name: '_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, + dataset: { type: 'dataView', id: dataViewId }, }, ], }; 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'); }); @@ -84,26 +78,86 @@ describe('Serialization utils', () => { test('by reference', async () => { discoverServiceMock.savedSearch.get = jest.fn().mockReturnValue({ savedObjectId: 'savedSearch', + title: 'saved search title', + description: '', + columns: ['_source'], + sort: [['order_date', 'desc']], + searchSource: {}, ...(await discoverServiceMock.savedSearch.byValueToSavedSearch( { - attributes: mockedSavedSearchAttributes, + attributes: { + title: 'saved search title', + description: '', + columns: ['_source'], + sort: [['order_date', 'desc']], + grid: {}, + hideChart: false, + isTextBasedQuery: false, + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + tabs: [], + }, }, true )), }); - 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, }; 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 () => { + discoverServiceMock.savedSearch.get = jest.fn().mockReturnValue({ + savedObjectId: 'savedSearch', + title: 'saved search title', + description: '', + columns: ['_source'], + sort: [['order_date', 'desc']], + searchSource: {}, + ...(await discoverServiceMock.savedSearch.byValueToSavedSearch( + { + attributes: { + title: 'saved search title', + description: '', + columns: ['_source'], + sort: [['order_date', 'desc']], + grid: {}, + hideChart: false, + isTextBasedQuery: false, + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + tabs: [], + }, + }, + true + )), + }); + + const apiStateByRef: DiscoverSessionEmbeddableByReferenceState = { + title: 'test panel title', + description: 'My description', + discover_session_id: 'savedSearch', + selected_tab_id: undefined, + sort: [['order_date', 'asc']], + }; + + const deserializedState = await deserializeState({ + serializedState: apiStateByRef, + discoverServices: discoverServiceMock, + }); + expect(deserializedState.title).toEqual('test panel title'); expect(deserializedState.sort).toEqual([['order_date', 'asc']]); }); @@ -115,7 +169,14 @@ describe('Serialization utils', () => { index: dataViewMock, }); const savedSearch = { - ...mockedSavedSearchAttributes, + title: 'test1', + description: 'description', + columns: ['_source'], + sort: [['order_date', 'desc']], + grid: {}, + hideChart: false, + sampleSize: 100, + isTextBasedQuery: false, managed: false, searchSource, }; @@ -123,29 +184,29 @@ describe('Serialization utils', () => { const serializedState = serializeState({ uuid, initialState: { - ...mockedSavedSearchAttributes, + ...savedSearch, serializedSearchSource: {} as SerializedSearchSourceFields, }, - savedSearch, - serializeTitles: jest.fn(), + savedSearch: savedSearch as Parameters[0]['savedSearch'], + serializeTitles: jest.fn().mockReturnValue({ title: 'test1', description: 'description' }), serializeTimeRange: jest.fn(), serializeDynamicActions: jest.fn(), }); - 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({ + columns: [{ name: '_source' }], + sort: [{ name: 'order_date', direction: 'desc' }], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + dataset: { type: 'dataView', id: dataViewId }, + }), + ], }); + expect(serializedState).not.toHaveProperty('attributes'); }); describe('by reference', () => { @@ -154,7 +215,14 @@ describe('Serialization utils', () => { }); const savedSearch = { - ...mockedSavedSearchAttributes, + title: 'test1', + description: 'description', + columns: ['_source'], + sort: [['order_date', 'desc']], + grid: {}, + hideChart: false, + sampleSize: 100, + isTextBasedQuery: false, managed: false, searchSource, }; @@ -165,16 +233,17 @@ describe('Serialization utils', () => { initialState: { rawSavedObjectAttributes: savedSearch, }, - savedSearch, + savedSearch: savedSearch as Parameters[0]['savedSearch'], serializeTitles: jest.fn(), serializeTimeRange: jest.fn(), serializeDynamicActions: jest.fn(), savedObjectId: 'test-id', }); - expect(serializedState).toEqual({ - savedObjectId: 'test-id', + expect(serializedState).toMatchObject({ + discover_session_id: 'test-id', }); + expect(serializedState).not.toHaveProperty('savedObjectId'); }); test('overwrite state', () => { @@ -183,17 +252,21 @@ describe('Serialization utils', () => { initialState: { rawSavedObjectAttributes: savedSearch, }, - savedSearch: { ...savedSearch, sampleSize: 500, sort: [['order_date', 'asc']] }, + savedSearch: { + ...savedSearch, + sampleSize: 500, + sort: [['order_date', 'asc']], + } as Parameters[0]['savedSearch'], serializeTitles: jest.fn(), serializeTimeRange: jest.fn(), serializeDynamicActions: jest.fn(), savedObjectId: 'test-id', }); - expect(serializedState).toEqual({ - sampleSize: 500, - sort: [['order_date', 'asc']], - savedObjectId: 'test-id', + // TODO: 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', }); }); }); 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 4aa53ac074654..bba7df9798fe7 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 @@ -11,42 +11,51 @@ 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 { SavedSearchType } from '@kbn/saved-search-plugin/common'; import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; +import { + byReferenceSavedSearchToDiscoverSessionEmbeddableState, + byValueDiscoverSessionToSavedSearchEmbeddableState, + byValueSavedSearchToDiscoverSessionEmbeddableState, +} from '../../../common/embeddable/transform_utils'; +import { isByReferenceDiscoverSessionEmbeddableState } from '../../../common'; import { EDITABLE_SAVED_SEARCH_KEYS } from '../../../common/embeddable/constants'; +import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from '../../../common/embeddable/constants'; import type { - SearchEmbeddableByReferenceState, - SearchEmbeddableByValueState, - SearchEmbeddableState, + StoredSearchEmbeddableByReferenceState, + StoredSearchEmbeddableState, } from '../../../common/embeddable/types'; +import type { DiscoverSessionEmbeddableState } from '../../../server'; import type { DiscoverServices } from '../../build_services'; import { EDITABLE_PANEL_KEYS } from '../constants'; -import type { SearchEmbeddableRuntimeState } from '../types'; +import type { SearchEmbeddableInputState, SearchEmbeddableRuntimeState } from '../types'; 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) { + + if (isByReferenceDiscoverSessionEmbeddableState(serializedState)) { // by reference const { get } = discoverServices.savedSearch; - const so = await get(savedObjectId, true); + const so = await get(serializedState.discover_session_id, true); const rawSavedObjectAttributes = pick(so, EDITABLE_SAVED_SEARCH_KEYS); const savedObjectOverride = pick(serializedState, EDITABLE_SAVED_SEARCH_KEYS); return { // ignore the time range from the saved object - only global time range + panel time range matter ...omit(so, 'timeRange'), - savedObjectId, + savedObjectId: serializedState.discover_session_id, savedObjectTitle: so.title, savedObjectDescription: so.description, // Overwrite SO state with dashboard state for title, description, columns, sort, etc. ...panelState, ...savedObjectOverride, + nonPersistedDisplayOptions: serializedState.nonPersistedDisplayOptions, // back up the original saved object attributes for comparison rawSavedObjectAttributes, @@ -55,8 +64,10 @@ export const deserializeState = async ({ // by value const { byValueToSavedSearch } = discoverServices.savedSearch; + const { state: storedState, references } = + byValueDiscoverSessionToSavedSearchEmbeddableState(serializedState); const savedSearch = await byValueToSavedSearch( - serializedState as SearchEmbeddableByValueState, + { attributes: { ...storedState.attributes, references } }, true ); return { @@ -83,7 +94,7 @@ export const serializeState = ({ serializeTimeRange: () => SerializedTimeRange; serializeDynamicActions: () => SerializedDrilldowns; savedObjectId?: string; -}): SearchEmbeddableState => { +}): DiscoverSessionEmbeddableState => { const searchSource = savedSearch.searchSource; const searchSourceJSON = JSON.stringify(searchSource.getSerializedFields()); const savedSearchAttributes = toSavedSearchAttributes(savedSearch, searchSourceJSON); @@ -100,20 +111,23 @@ export const serializeState = ({ return { ...prev, [key]: attributes[key] }; }, {}); - return { - // Serialize the current dashboard state into the panel state **without** updating the saved object + const storedByRef: StoredSearchEmbeddableByReferenceState = { ...serializeTitles(), ...serializeTimeRange(), ...serializeDynamicActions?.(), ...overwriteState, - savedObjectId, }; + const refs = [ + { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: savedObjectId }, + ]; + return byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedByRef, refs); } - return { + const stored: StoredSearchEmbeddableState = { ...serializeTitles(), ...serializeTimeRange(), ...serializeDynamicActions?.(), attributes: savedSearchAttributes, }; + return byValueSavedSearchToDiscoverSessionEmbeddableState(stored, []); }; diff --git a/src/platform/plugins/shared/discover/server/embeddable/index.ts b/src/platform/plugins/shared/discover/server/embeddable/index.ts index e206486a980f0..d53dfb81c6bab 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/index.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/index.ts @@ -8,3 +8,14 @@ */ export { createSearchEmbeddableFactory } from './search_embeddable_factory'; +export type { + DiscoverSessionDataViewReference, + DiscoverSessionDataViewSpec, + DiscoverSessionDataset, + DiscoverSessionClassicTab, + DiscoverSessionEsqlTab, + DiscoverSessionTab, + 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 e9414b50922b9..3e4da5d153fd0 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -11,9 +11,14 @@ import type { 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 { + type SerializedTitles, + serializedTitlesSchema, +} from '@kbn/presentation-publishing-schemas'; import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; import { asCodeFilterSchema } from '@kbn/as-code-filters-schema'; +import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; +import type { SerializedTimeRange } from '@kbn/presentation-publishing'; const columnSchema = schema.object({ name: schema.string({ @@ -102,11 +107,23 @@ export const dataViewSpecSchema = schema.object( * The type of the runtime field (e.g., 'keyword', 'long', 'date'). * Example: 'keyword' */ - type: schema.string({ - meta: { - description: 'The type of the runtime field (e.g., "keyword", "long", "date").', - }, - }), + type: schema.oneOf( + [ + schema.literal('keyword'), + schema.literal('long'), + schema.literal('double'), + schema.literal('date'), + schema.literal('ip'), + schema.literal('boolean'), + schema.literal('geo_point'), + schema.literal('composite'), + ], + { + meta: { + description: 'The type of the runtime field (e.g., "keyword", "long", "date").', + }, + } + ), /** * The name of the runtime field. * Example: 'my_runtime_field' @@ -242,6 +259,7 @@ const dataTableSchema = schema.object( schema.literal('auto'), ], { + defaultValue: 3, meta: { description: 'Header row height. Use a number (1–5) or "auto" to size based on content.', }, @@ -330,12 +348,20 @@ export const discoverSessionEmbeddableSchema = schema.oneOf([ discoverSessionByReferenceEmbeddableSchema, ]); +export type DiscoverSessionDataViewReference = TypeOf; +export type DiscoverSessionDataViewSpec = TypeOf; +export type DiscoverSessionDataset = TypeOf; +export type DiscoverSessionClassicTab = TypeOf; +export type DiscoverSessionEsqlTab = TypeOf; +export type DiscoverSessionTab = DiscoverSessionClassicTab | DiscoverSessionEsqlTab; + export type DiscoverSessionEmbeddableByValueState = TypeOf< typeof discoverSessionByValueEmbeddableSchema >; export type DiscoverSessionEmbeddableByReferenceState = TypeOf< typeof discoverSessionByReferenceEmbeddableSchema >; -export type DiscoverSessionEmbeddableState = - | DiscoverSessionEmbeddableByValueState - | DiscoverSessionEmbeddableByReferenceState; +export type DiscoverSessionEmbeddableState = SerializedDrilldowns & + SerializedTitles & + SerializedTimeRange & + (DiscoverSessionEmbeddableByValueState | DiscoverSessionEmbeddableByReferenceState); diff --git a/src/platform/plugins/shared/discover/server/index.ts b/src/platform/plugins/shared/discover/server/index.ts index 93182d9d298b9..47a830160ae53 100644 --- a/src/platform/plugins/shared/discover/server/index.ts +++ b/src/platform/plugins/shared/discover/server/index.ts @@ -40,6 +40,17 @@ export interface DiscoverServerPluginStart { } export { config } from './config'; +export type { + DiscoverSessionDataViewReference, + DiscoverSessionDataViewSpec, + DiscoverSessionDataset, + DiscoverSessionClassicTab, + DiscoverSessionEsqlTab, + DiscoverSessionTab, + DiscoverSessionEmbeddableByValueState, + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableState, +} from './embeddable'; export const plugin = async (context: PluginInitializerContext) => { const { DiscoverServerPlugin } = await import('./plugin'); 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 4cc6b3ce1f438..11051e2ab3ce1 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), + ]) ), }); From 675fe97bac321f0d51ae8cff6037cf9b8043a94a Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Fri, 27 Feb 2026 12:52:47 -0700 Subject: [PATCH 02/33] Fix add panel from library --- .../public/embeddable/utils/add_panel_from_library.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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..2afa334d211fc 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,11 @@ 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, }, }, { From 47f580b36cccf77ecd80d58e2a78c10f7b795945 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Tue, 3 Mar 2026 15:50:56 -0700 Subject: [PATCH 03/33] time_range changes --- .../search_embeddable_transforms.test.ts | 12 ++--- .../common/embeddable/transform_utils.test.ts | 12 ++--- .../common/embeddable/transform_utils.ts | 15 +++--- .../discover/server/embeddable/schema.ts | 52 +++++++++++-------- 4 files changed, 50 insertions(+), 41 deletions(-) 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 e02cfaf4f1a6c..c8c694c3b26ed 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 @@ -39,7 +39,7 @@ describe('searchEmbeddableTransforms', () => { const state: StoredSearchEmbeddableState = { title: 'Test Title', description: 'Test Description', - timeRange: { from: 'now-15m', to: 'now' }, + time_range: { from: 'now-15m', to: 'now' }, }; const references = [ { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-123' }, @@ -51,7 +51,7 @@ describe('searchEmbeddableTransforms', () => { expect(result).toEqual({ title: 'Test Title', description: 'Test Description', - timeRange: { from: 'now-15m', to: 'now' }, + time_range: { from: 'now-15m', to: 'now' }, discover_session_id: 'session-123', selected_tab_id: undefined, }); @@ -155,7 +155,7 @@ describe('searchEmbeddableTransforms', () => { const apiState: DiscoverSessionEmbeddableByReferenceState = { title: 'Test Search', description: 'Test Description', - timeRange: { from: 'now-15m', to: 'now' }, + time_range: { from: 'now-15m', to: 'now' }, discover_session_id: 'test-saved-object-id', selected_tab_id: undefined, }; @@ -166,7 +166,7 @@ describe('searchEmbeddableTransforms', () => { expect(result.state).toEqual({ title: 'Test Search', description: 'Test Description', - timeRange: { from: 'now-15m', to: 'now' }, + time_range: { from: 'now-15m', to: 'now' }, }); expect(result.references).toEqual([ { @@ -182,7 +182,7 @@ describe('searchEmbeddableTransforms', () => { const apiState: DiscoverSessionEmbeddableByReferenceState = { title: 'My Search', description: 'My description', - timeRange: { from: 'now-1h', to: 'now' }, + time_range: { from: 'now-1h', to: 'now' }, discover_session_id: 'session-456', selected_tab_id: 'tab-1', }; @@ -193,7 +193,7 @@ describe('searchEmbeddableTransforms', () => { expect(result.state).toEqual({ title: 'My Search', description: 'My description', - timeRange: { from: 'now-1h', to: 'now' }, + time_range: { from: 'now-1h', to: 'now' }, }); expect(result.references).toEqual([ { 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 index d53034b0c4c83..c90d6a1a7f92a 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -133,7 +133,7 @@ describe('search embeddable transform utils', () => { const storedSearch: StoredSearchEmbeddableByReferenceState = { title: 'My Saved Search', description: 'My description', - timeRange: { from: 'now-15m', to: 'now' }, + time_range: { from: 'now-15m', to: 'now' }, }; const references: SavedObjectReference[] = [ { name: 'savedObjectRef', type: SavedSearchType, id: 'session-123' }, @@ -145,7 +145,7 @@ describe('search embeddable transform utils', () => { expect(result).toEqual({ title: 'My Saved Search', description: 'My description', - timeRange: { from: 'now-15m', to: 'now' }, + time_range: { from: 'now-15m', to: 'now' }, discover_session_id: 'session-123', selected_tab_id: undefined, }); @@ -157,7 +157,7 @@ describe('search embeddable transform utils', () => { const apiState: DiscoverSessionEmbeddableByReferenceState = { title: 'My Search', description: 'My description', - timeRange: { from: 'now-15m', to: 'now' }, + time_range: { from: 'now-15m', to: 'now' }, discover_session_id: 'session-456', selected_tab_id: 'tab-1', }; @@ -172,7 +172,7 @@ describe('search embeddable transform utils', () => { expect(result.state).toEqual({ title: 'My Search', description: 'My description', - timeRange: { from: 'now-15m', to: 'now' }, + time_range: { from: 'now-15m', to: 'now' }, }); }); }); @@ -182,7 +182,7 @@ describe('search embeddable transform utils', () => { const apiState: DiscoverSessionEmbeddableByValueState = { title: 'Panel Title', description: 'Panel description', - timeRange: { from: 'now-1h', to: 'now' }, + time_range: { from: 'now-1h', to: 'now' }, tabs: [ { columns: [{ name: 'message' }, { name: '@timestamp', width: 200 }], @@ -229,7 +229,7 @@ describe('search embeddable transform utils', () => { it('converts index-pattern tab with runtime fields to stored state', () => { const apiState: DiscoverSessionEmbeddableByValueState = { title: 'Adhoc', - timeRange: { from: 'now-1h', to: 'now' }, + time_range: { from: 'now-1h', to: 'now' }, tabs: [ { columns: [{ name: 'foo' }], diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts index 66a4a8de93109..00508f53fc50e 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -71,7 +71,7 @@ export function byReferenceSavedSearchToDiscoverSessionEmbeddableState( storedSearch: StoredSearchEmbeddableByReferenceState, references: SavedObjectReference[] = [] ): DiscoverSessionEmbeddableByReferenceState { - const { title, description, timeRange } = storedSearch; + const { title, description, time_range: timeRange } = storedSearch; const savedObjectRef = references.find( (ref) => SavedSearchType === ref.type && ref.name === SAVED_SEARCH_SAVED_OBJECT_REF_NAME ); @@ -79,7 +79,7 @@ export function byReferenceSavedSearchToDiscoverSessionEmbeddableState( return { title, description, - timeRange, + time_range: timeRange, discover_session_id: savedObjectRef.id, selected_tab_id: undefined, // Waiting on https://github.com/elastic/kibana/pull/252311 }; @@ -94,15 +94,18 @@ export function byReferenceDiscoverSessionToSavedSearchEmbeddableState( type: SavedSearchType, id: apiState.discover_session_id, }; - const { discover_session_id, selected_tab_id, timeRange, ...state } = apiState; - return { state: { ...state, timeRange }, references: [discoverSessionReference, ...references] }; + const { discover_session_id, selected_tab_id, time_range: timeRange, ...state } = apiState; + return { + state: { ...state, time_range: timeRange }, + references: [discoverSessionReference, ...references], + }; } export function byValueSavedSearchToDiscoverSessionEmbeddableState( storedSearch: StoredSearchEmbeddableByValueState, references: SavedObjectReference[] = [] ): DiscoverSessionEmbeddableByValueState { - const { title, description, timeRange } = storedSearch; + const { title, description, time_range: timeRange } = storedSearch; const [tab] = storedSearch.attributes.tabs ?? extractTabs(storedSearch.attributes).tabs; const { columns, @@ -151,7 +154,7 @@ export function byValueSavedSearchToDiscoverSessionEmbeddableState( return { title, description, - timeRange, + time_range: timeRange, tabs: [newTab], }; } diff --git a/src/platform/plugins/shared/discover/server/embeddable/schema.ts b/src/platform/plugins/shared/discover/server/embeddable/schema.ts index 3e4da5d153fd0..5844615e76269 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -10,10 +10,11 @@ import type { 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 { aggregateQuerySchema, querySchema } from '@kbn/es-query-server'; import { type SerializedTitles, serializedTitlesSchema, + serializedTimeRangeSchema, } from '@kbn/presentation-publishing-schemas'; import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; import { asCodeFilterSchema } from '@kbn/as-code-filters-schema'; @@ -317,31 +318,36 @@ 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 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.', - }, - }), -}); - -const discoverSessionByReferenceEmbeddableSchema = discoverSessionBaseEmbeddableSchema.extends({ - discover_session_id: schema.string(), - selected_tab_id: schema.maybe( - schema.string({ +const discoverSessionByValueEmbeddableSchema = schema.allOf([ + serializedTitlesSchema, + serializedTimeRangeSchema, + 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.', + 'Array of tabs for the Discover session embeddable. Currently supports one tab.', }, - }) - ), -}); + }), + }), +]); + +const discoverSessionByReferenceEmbeddableSchema = schema.allOf([ + serializedTitlesSchema, + serializedTimeRangeSchema, + schema.object({ + discover_session_id: schema.string(), + selected_tab_id: schema.maybe( + schema.string({ + meta: { + description: + 'The selected tab in the Discover session. If omitted, defaults to the first tab.', + }, + }) + ), + }), +]); export const discoverSessionEmbeddableSchema = schema.oneOf([ discoverSessionByValueEmbeddableSchema, From 3293434c9264671f032cedb2f90095fe477d97d7 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Wed, 4 Mar 2026 14:53:57 -0700 Subject: [PATCH 04/33] Move view_mode to classic tab --- .../search_embeddable_transforms.test.ts | 14 ++++++--- .../common/embeddable/transform_utils.ts | 2 +- .../discover/server/embeddable/schema.ts | 30 ++++++++++--------- .../plugins/shared/discover/tsconfig.json | 1 + 4 files changed, 28 insertions(+), 19 deletions(-) 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 c8c694c3b26ed..8bf9d60c777ec 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 @@ -117,10 +117,16 @@ describe('searchEmbeddableTransforms', () => { { name: 'message' }, { name: '@timestamp', width: 200 }, ]); - expect(result.tabs[0].sort).toEqual([{ name: '@timestamp', direction: 'desc' }]); - expect(result.tabs[0].view_mode).toBe(VIEW_MODE.DOCUMENT_LEVEL); - expect(result.tabs[0].density).toBe(DataGridDensity.COMPACT); - expect((result.tabs[0] as DiscoverSessionClassicTab).dataset).toEqual({ + const { + sort, + view_mode: viewMode, + density, + dataset, + } = 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(dataset).toEqual({ type: 'dataView', id: 'data-view-1', }); diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts index 00508f53fc50e..76d9dc48209ec 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -184,7 +184,7 @@ export function byValueDiscoverSessionToSavedSearchEmbeddableState( grid: toStoredGrid(tab.columns), hideChart: false, isTextBasedQuery: !('dataset' in tab), - viewMode: tab.view_mode, + ...('view_mode' in tab && { view_mode: tab.view_mode }), rowHeight: tab.row_height === 'auto' || tab.row_height === undefined ? -1 : tab.row_height, headerRowHeight: tab.header_row_height === 'auto' || tab.header_row_height === undefined diff --git a/src/platform/plugins/shared/discover/server/embeddable/schema.ts b/src/platform/plugins/shared/discover/server/embeddable/schema.ts index 5844615e76269..5a95eb30a4290 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -168,6 +168,21 @@ export const dataViewSpecSchema = schema.object( export const dataViewSchema = schema.oneOf([dataViewReferenceSchema, dataViewSpecSchema]); +export const viewModeSchema = 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, + meta: { + description: + 'Discover view mode. Choose "documents" (search hits), "patterns" (pattern analysis), or "aggregated" (field statistics).', + }, + } +); + const dataTableLimitsSchema = schema.object( { rows_per_page: schema.maybe( @@ -223,20 +238,6 @@ 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, - meta: { - description: - 'Discover view mode. Choose "documents" (search hits), "patterns" (pattern analysis), or "aggregated" (field statistics).', - }, - } - ), density: schema.oneOf( [ schema.literal(DataGridDensity.COMPACT), @@ -301,6 +302,7 @@ const classicTabSchema = schema.allOf([ }, }), dataset: dataViewSchema, + view_mode: viewModeSchema, }), ]); diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index b99de7cb685b3..159b428b08aa2 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -126,6 +126,7 @@ "@kbn/esql", "@kbn/controls-schemas", "@kbn/as-code-filters-schema", + "@kbn/as-code-filters-transforms", "@kbn/scout", "@kbn/synthtrace-client", "@kbn/cps-utils" From a4f4a6d1256f1f28558e09148d83d502c756ebfb Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Thu, 5 Mar 2026 12:38:21 -0700 Subject: [PATCH 05/33] Add override state to schema, clean up transform utils --- .../common/embeddable/get_transform_in.ts | 5 +- .../common/embeddable/get_transform_out.ts | 5 +- .../search_embeddable_transforms.test.ts | 5 + .../common/embeddable/transform_utils.test.ts | 399 +++++++++++++++++- .../common/embeddable/transform_utils.ts | 308 +++++++++----- .../discover/server/embeddable/index.ts | 1 + .../discover/server/embeddable/schema.ts | 120 +++++- .../plugins/shared/discover/server/index.ts | 1 + 8 files changed, 705 insertions(+), 139 deletions(-) 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 078a9177eb33d..c436a02fb7ed4 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 @@ -16,7 +16,7 @@ import type { StoredSearchEmbeddableState } from './types'; export { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; export function getTransformIn(transformDrilldownsIn: DrilldownTransforms['transformIn']) { - function transformIn(state: DiscoverSessionEmbeddableState): { + return function transformIn(state: DiscoverSessionEmbeddableState): { state: StoredSearchEmbeddableState; references: SavedObjectReference[]; } { @@ -24,6 +24,5 @@ export function getTransformIn(transformDrilldownsIn: DrilldownTransforms['trans transformDrilldownsIn(state); return discoverSessionToSavedSearchEmbeddableState(storedState, drilldownReferences); - } - return transformIn; + }; } 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 99273d5fb1766..c71709e9a3c96 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 @@ -15,7 +15,7 @@ import { savedSearchToDiscoverSessionEmbeddableState } from './transform_utils'; import type { StoredSearchEmbeddableState } from './types'; export function getTransformOut(transformDrilldownsOut: DrilldownTransforms['transformOut']) { - function transformOut( + return function transformOut( storedState: StoredSearchEmbeddableState, references?: SavedObjectReference[] ) { @@ -26,6 +26,5 @@ export function getTransformOut(transformDrilldownsOut: DrilldownTransforms['tra ); const state = transformsFlow(storedState); return savedSearchToDiscoverSessionEmbeddableState(state, references); - } - return transformOut; + }; } 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 8bf9d60c777ec..4d11112c3dc3c 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 @@ -130,6 +130,11 @@ describe('searchEmbeddableTransforms', () => { type: 'dataView', id: 'data-view-1', }); + expect(result.columns).toEqual(result.tabs[0].columns); + expect(result.sort).toEqual(result.tabs[0].sort); + expect(result.density).toBe(DataGridDensity.COMPACT); + expect(result.header_row_height).toBe('auto'); + expect(result.row_height).toBe('auto'); expect(mockDrilldownTransforms.transformOut).toHaveBeenCalledWith(state, references); }); 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 index c90d6a1a7f92a..9f2409836e57f 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -13,20 +13,30 @@ import { byReferenceSavedSearchToDiscoverSessionEmbeddableState, byValueDiscoverSessionToSavedSearchEmbeddableState, byValueSavedSearchToDiscoverSessionEmbeddableState, + discoverSessionToSavedSearchEmbeddableState, fromStoredColumns, fromStoredDataset, + fromStoredHeight, fromStoredRuntimeFields, fromStoredSort, + fromStoredTab, + isByReferenceDiscoverSessionEmbeddableState, + isByReferenceSavedSearchEmbeddableState, + savedSearchToDiscoverSessionEmbeddableState, toStoredColumns, toStoredDataset, + toStoredFieldFormats, toStoredGrid, + toStoredHeight, toStoredRuntimeFields, toStoredSort, + toStoredTab, } from './transform_utils'; import type { StoredSearchEmbeddableByReferenceState, StoredSearchEmbeddableByValueState, } from './types'; +import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; import { SavedSearchType } from '@kbn/saved-search-plugin/common'; import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; import type { @@ -46,6 +56,189 @@ describe('search embeddable transform utils', () => { jest.clearAllMocks(); }); + describe('isByReferenceSavedSearchEmbeddableState', () => { + it('returns true when state has no attributes (by-reference)', () => { + const state: StoredSearchEmbeddableByReferenceState = { + title: 'My Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + }; + expect(isByReferenceSavedSearchEmbeddableState(state)).toBe(true); + }); + + it('returns false when state has attributes (by-value)', () => { + const state = { + title: 'My Search', + description: 'My description', + attributes: { + tabs: [], + title: '', + description: '', + sort: [], + columns: [], + grid: {}, + hideChart: false, + isTextBasedQuery: false, + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + }, + } as unknown as StoredSearchEmbeddableByValueState; + expect(isByReferenceSavedSearchEmbeddableState(state)).toBe(false); + }); + }); + + describe('isByReferenceDiscoverSessionEmbeddableState', () => { + it('returns true when state has discover_session_id', () => { + const state: DiscoverSessionEmbeddableByReferenceState = { + title: 'My Search', + description: 'My description', + time_range: { from: 'now-15m', to: 'now' }, + discover_session_id: 'session-123', + selected_tab_id: undefined, + }; + expect(isByReferenceDiscoverSessionEmbeddableState(state)).toBe(true); + }); + + it('returns false when state has tabs (by-value)', () => { + const state: DiscoverSessionEmbeddableByValueState = { + title: 'My Search', + description: 'My description', + tabs: [ + { + columns: [{ name: 'message' }], + sort: [], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 'auto', + row_height: 'auto', + query: { language: 'kuery', query: '' }, + filters: [], + dataset: { type: 'dataView', id: 'dv-1' }, + }, + ], + }; + expect(isByReferenceDiscoverSessionEmbeddableState(state)).toBe(false); + }); + }); + + describe('savedSearchToDiscoverSessionEmbeddableState', () => { + 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 = savedSearchToDiscoverSessionEmbeddableState(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, + }); + }); + + 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 = savedSearchToDiscoverSessionEmbeddableState(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); + }); + }); + + describe('discoverSessionToSavedSearchEmbeddableState', () => { + 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, + }; + const { state, references } = discoverSessionToSavedSearchEmbeddableState(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: [ + { + columns: [{ name: 'message' }], + sort: [], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 'auto', + row_height: 'auto', + query: { language: 'kuery', query: '' }, + filters: [], + dataset: { type: 'dataView', id: 'data-view-1' }, + }, + ], + }; + const { state, references } = discoverSessionToSavedSearchEmbeddableState(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('byValueSavedSearchToDiscoverSessionEmbeddableState', () => { it('converts to DiscoverSessionEmbeddableByValueState', () => { const storedState: StoredSearchEmbeddableByValueState = { @@ -91,7 +284,6 @@ describe('search embeddable transform utils', () => { const expected: DiscoverSessionEmbeddableByValueState = { title: '[filebeat-*] elasticsearch logs', description: 'my description', - // type: 'search', tabs: [ { query: { language: 'kuery', query: 'service.type: "elasticsearch"' }, @@ -112,8 +304,7 @@ describe('search embeddable transform utils', () => { columns: [{ name: 'message' }], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, - header_row_height: 'auto', - row_height: 'auto', + header_row_height: 3, dataset: { type: 'dataView', id: 'c7d7a1f5-19da-4ba9-af15-5919e8cd2528', @@ -164,7 +355,7 @@ describe('search embeddable transform utils', () => { const result = byReferenceDiscoverSessionToSavedSearchEmbeddableState(apiState); expect(result.references).toEqual([ { - name: 'savedObjectRef', + name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-456', }, @@ -173,6 +364,7 @@ describe('search embeddable transform utils', () => { title: 'My Search', description: 'My description', time_range: { from: 'now-15m', to: 'now' }, + grid: { columns: {} }, }); }); }); @@ -212,18 +404,15 @@ describe('search embeddable transform utils', () => { expect(result.state.attributes.tabs).toHaveLength(1); 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.viewMode).toBe(VIEW_MODE.DOCUMENT_LEVEL); expect(result.state.attributes.tabs[0].attributes.rowHeight).toBe(-1); expect(result.state.attributes.tabs[0].attributes.headerRowHeight).toBe(-1); - expect( - JSON.parse( - result.state.attributes.tabs[0].attributes.kibanaSavedObjectMeta.searchSourceJSON - ) - ).toEqual({ - index: 'data-view-1', - query: { language: 'kuery', query: '' }, - filter: [], - }); + 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', () => { @@ -395,6 +584,32 @@ describe('search embeddable transform utils', () => { }); }); + 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('fromStoredDataset', () => { it('throws when index is null', () => { expect(() => fromStoredDataset(null as unknown as string)).toThrow( @@ -642,4 +857,160 @@ describe('search embeddable transform utils', () => { expect(toStoredRuntimeFields([])).toEqual({}); }); }); + + describe('toStoredFieldFormats', () => { + it('converts runtime fields with format to fieldFormats object', () => { + const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ + { + name: 'rt', + type: 'keyword', + script: 'emit("x")', + format: { id: 'string' }, + }, + ]; + const result = toStoredFieldFormats(runtimeFields); + expect(result).toEqual({ + rt: { id: 'string' }, + }); + }); + + it('omits entries when format is missing', () => { + const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ + { name: 'rt', type: 'keyword', script: 'emit("x")' }, + ]; + const result = toStoredFieldFormats(runtimeFields); + expect(result).toEqual({}); + }); + + it('returns undefined when runtimeFields is undefined (default)', () => { + expect(toStoredFieldFormats()).toBeUndefined(); + }); + + it('returns undefined when runtimeFields is empty array', () => { + expect(toStoredFieldFormats([])).toBeUndefined(); + }); + + it('includes only runtime fields that have format', () => { + const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ + { name: 'a', type: 'keyword', format: { id: 'url' } }, + { name: 'b', type: 'long' }, + { name: 'c', type: 'date', format: { id: 'date' } }, + ]; + const result = toStoredFieldFormats(runtimeFields); + expect(result).toEqual({ + a: { id: 'url' }, + c: { id: 'date' }, + }); + }); + }); + + 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.columns).toEqual([ + { name: 'message' }, + { name: '@timestamp', width: 200 }, + ]); + expect(result.row_height).toBe('auto'); + expect(result.header_row_height).toBe('auto'); + expect(result.density).toBe(DataGridDensity.COMPACT); + expect('dataset' in result && result.dataset).toEqual({ + type: 'dataView', + 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] = { + columns: [{ name: 'message' }, { name: '@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, + dataset: { type: 'dataView', 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 dataset (no refs) when inline', () => { + const apiTab: DiscoverSessionEmbeddableByValueState['tabs'][0] = { + columns: [{ name: 'foo' }], + sort: [], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 3, + row_height: 3, + query: { language: 'kuery', query: '' }, + filters: [], + dataset: { + type: 'index', + index: '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 index 76d9dc48209ec..5aa13f2476ff9 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -8,19 +8,18 @@ */ import type { DiscoverSessionTabAttributes } from '@kbn/saved-search-plugin/server'; -import { - extractTabs, - type SavedSearchByValueAttributes, - SavedSearchType, - VIEW_MODE, -} from '@kbn/saved-search-plugin/common'; -import { DataGridDensity } from '@kbn/discover-utils'; +import type { SavedSearchAttributes } from '@kbn/saved-search-plugin/common'; +import { extractTabs, SavedSearchType, VIEW_MODE } from '@kbn/saved-search-plugin/common'; import type { DataViewSpec, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; -import { extractReferences } from '@kbn/data-plugin/common'; -import { injectReferences, parseSearchSourceJSON } from '@kbn/data-plugin/common'; +import { + extractReferences, + injectReferences, + parseSearchSourceJSON, +} from '@kbn/data-plugin/common'; import { fromStoredFilters, toStoredFilters } from '@kbn/as-code-filters-transforms'; -import { isOfAggregateQueryType } from '@kbn/es-query'; import type { SavedObjectReference } from '@kbn/core-saved-objects-common/src/server_types'; +import { DataGridDensity } from '@kbn/discover-utils'; +import { isOfAggregateQueryType } from '@kbn/es-query'; import type { DiscoverSessionClassicTab, DiscoverSessionDataset, @@ -50,12 +49,12 @@ export function isByReferenceDiscoverSessionEmbeddableState( } export function savedSearchToDiscoverSessionEmbeddableState( - storedSearch: StoredSearchEmbeddableState, + storedState: StoredSearchEmbeddableState, references: SavedObjectReference[] = [] ): DiscoverSessionEmbeddableState { - return isByReferenceSavedSearchEmbeddableState(storedSearch) - ? byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedSearch, references) - : byValueSavedSearchToDiscoverSessionEmbeddableState(storedSearch, references); + return isByReferenceSavedSearchEmbeddableState(storedState) + ? byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedState, references) + : byValueSavedSearchToDiscoverSessionEmbeddableState(storedState, references); } export function discoverSessionToSavedSearchEmbeddableState( @@ -68,18 +67,35 @@ export function discoverSessionToSavedSearchEmbeddableState( } export function byReferenceSavedSearchToDiscoverSessionEmbeddableState( - storedSearch: StoredSearchEmbeddableByReferenceState, + storedState: StoredSearchEmbeddableByReferenceState, references: SavedObjectReference[] = [] ): DiscoverSessionEmbeddableByReferenceState { - const { title, description, time_range: timeRange } = storedSearch; 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}"`); + const { + sort, + columns, + rowHeight, + sampleSize, + rowsPerPage, + headerRowHeight, + density, + grid, + ...otherAttrs + } = storedState; return { - title, - description, - time_range: timeRange, + ...otherAttrs, + ...(sort && { sort: fromStoredSort(sort) }), + ...(columns && { columns: fromStoredColumns(columns, grid) }), + ...(rowHeight && { row_height: fromStoredHeight(rowHeight) }), + ...(sampleSize && { sample_size: sampleSize }), + ...(rowsPerPage && { + rows_per_page: rowsPerPage as DiscoverSessionEmbeddableState['rows_per_page'], + }), + ...(headerRowHeight && { header_row_height: fromStoredHeight(headerRowHeight) }), + ...(density && { density }), discover_session_id: savedObjectRef.id, selected_tab_id: undefined, // Waiting on https://github.com/elastic/kibana/pull/252311 }; @@ -94,134 +110,192 @@ export function byReferenceDiscoverSessionToSavedSearchEmbeddableState( type: SavedSearchType, id: apiState.discover_session_id, }; - const { discover_session_id, selected_tab_id, time_range: timeRange, ...state } = apiState; + const { + sort, + columns, + row_height: rowHeight, + sample_size: sampleSize, + rows_per_page: rowsPerPage, + header_row_height: headerRowHeight, + density, + discover_session_id, + selected_tab_id, + ...otherAttrs + } = apiState; + const state: StoredSearchEmbeddableByReferenceState = { + ...otherAttrs, + ...(sort && { sort: toStoredSort(sort) }), + ...(columns && { columns: toStoredColumns(columns) }), + ...(rowHeight && { rowHeight: toStoredHeight(rowHeight) }), + ...(sampleSize && { sampleSize }), + ...(rowsPerPage && { rowsPerPage }), + ...(headerRowHeight && { headerRowHeight: toStoredHeight(headerRowHeight) }), + ...(density && { density }), + grid: toStoredGrid(columns), + }; return { - state: { ...state, time_range: timeRange }, - references: [discoverSessionReference, ...references], + state, + references: [...references, discoverSessionReference], }; } export function byValueSavedSearchToDiscoverSessionEmbeddableState( - storedSearch: StoredSearchEmbeddableByValueState, + storedState: StoredSearchEmbeddableByValueState, references: SavedObjectReference[] = [] ): DiscoverSessionEmbeddableByValueState { - const { title, description, time_range: timeRange } = storedSearch; - const [tab] = storedSearch.attributes.tabs ?? extractTabs(storedSearch.attributes).tabs; 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); + return { + ...otherAttrs, + ...(sort && { sort: fromStoredSort(sort) }), + ...(columns && { columns: fromStoredColumns(columns, grid) }), + ...(rowHeight && { row_height: fromStoredHeight(rowHeight) }), + ...(sampleSize && { sample_size: sampleSize }), + ...(rowsPerPage && { + rows_per_page: rowsPerPage as DiscoverSessionEmbeddableState['rows_per_page'], + }), + ...(headerRowHeight && { header_row_height: fromStoredHeight(headerRowHeight) }), + ...(density && { density }), + tabs: [apiTab], + }; +} + +export function byValueDiscoverSessionToSavedSearchEmbeddableState( + apiState: DiscoverSessionEmbeddableByValueState, + references: SavedObjectReference[] = [] +): { state: StoredSearchEmbeddableByValueState; references: SavedObjectReference[] } { + const { sort, + columns, + row_height: rowHeight, + sample_size: sampleSize, + rows_per_page: rowsPerPage, + header_row_height: headerRowHeight, + density, + tabs: [apiTab], + ...otherAttrs + } = apiState; + const { state: tabAttributes, references: tabReferences } = toStoredTab(apiTab); + const state: StoredSearchEmbeddableByValueState = { + ...otherAttrs, + ...(sort && { sort: toStoredSort(sort) }), + ...(columns && { columns: toStoredColumns(columns) }), + ...(rowHeight && { rowHeight: toStoredHeight(rowHeight) }), + ...(sampleSize && { sampleSize }), + ...(rowsPerPage && { rowsPerPage }), + ...(headerRowHeight && { headerRowHeight: toStoredHeight(headerRowHeight) }), + ...(density && { density }), + grid: toStoredGrid(columns), + attributes: { + ...tabAttributes, + sort: tabAttributes.sort as SavedSearchAttributes['sort'], + title: apiState.title ?? '', // Only necessary for schema validation + description: apiState.description ?? '', // Only necessary for schema validation + tabs: [ + { + id: '', // Only necessary for schema validation + label: '', // Only necessary for schema validation + attributes: tabAttributes, + }, + ], + }, + }; + return { + state, + references: [...references, ...tabReferences], + }; +} + +export function fromStoredTab( + tab: DiscoverSessionTabAttributes, + references: SavedObjectReference[] = [] +): DiscoverSessionTab { + const { + sort, + columns, rowHeight, - headerRowHeight, - rowsPerPage, sampleSize, - viewMode, + rowsPerPage, + headerRowHeight, density, + grid, + viewMode, kibanaSavedObjectMeta: { searchSourceJSON }, - } = tab.attributes; - - const sharedAttrs = { - columns: fromStoredColumns(columns, grid), + } = tab; + const apiTab = { sort: fromStoredSort(sort), - view_mode: viewMode ?? VIEW_MODE.DOCUMENT_LEVEL, + ...(columns && { columns: fromStoredColumns(columns, grid) }), + ...(rowHeight && { row_height: fromStoredHeight(rowHeight) }), + header_row_height: fromStoredHeight(headerRowHeight)!, density: density ?? DataGridDensity.COMPACT, - header_row_height: (headerRowHeight === undefined || headerRowHeight === -1 - ? 'auto' - : headerRowHeight) as DiscoverSessionTab['header_row_height'], - row_height: (rowHeight === undefined || rowHeight === -1 - ? 'auto' - : rowHeight) as DiscoverSessionTab['row_height'], }; - const searchSourceValues = parseSearchSourceJSON(searchSourceJSON); - const searchSourceFields = injectReferences(searchSourceValues, references); - const { index, query, filter } = searchSourceFields; - - const newTab: DiscoverSessionTab = isOfAggregateQueryType(query) - ? { - ...sharedAttrs, - query, - } + const { index, query, filter } = injectReferences(searchSourceValues, references); + return isOfAggregateQueryType(query) + ? { ...apiTab, query } : { - ...sharedAttrs, + ...apiTab, + ...(sampleSize && { sample_size: sampleSize }), + ...(rowsPerPage && { + rows_per_page: rowsPerPage as DiscoverSessionClassicTab['rows_per_page'], + }), query, filters: fromStoredFilters(filter) ?? [], - rows_per_page: rowsPerPage as DiscoverSessionClassicTab['rows_per_page'], - sample_size: sampleSize, dataset: fromStoredDataset(index), + view_mode: viewMode ?? VIEW_MODE.DOCUMENT_LEVEL, }; - - return { - title, - description, - time_range: timeRange, - tabs: [newTab], - }; } -export function byValueDiscoverSessionToSavedSearchEmbeddableState( - apiState: DiscoverSessionEmbeddableByValueState, - references: SavedObjectReference[] = [] -): { state: StoredSearchEmbeddableByValueState; references: SavedObjectReference[] } { - if (!apiState.tabs?.length) { - throw new Error('Discover session by-value state must have at least one tab'); - } +export function toStoredTab(apiTab: DiscoverSessionTab): { + state: DiscoverSessionTabAttributes; + references: SavedObjectReference[]; +} { const { - tabs: [tab], - ...state - } = apiState; - - const searchSourceValues = { - index: 'dataset' in tab ? toStoredDataset(tab.dataset) : undefined, - query: tab.query, - filter: 'filters' in tab ? toStoredFilters(tab.filters) : undefined, + sort, + columns, + row_height: rowHeight, + header_row_height: headerRowHeight, + density, + } = apiTab; + const searchSourceValues: SerializedSearchSourceFields = { + query: apiTab.query, + ...('filters' in apiTab && { filter: toStoredFilters(apiTab.filters) }), + ...('dataset' in apiTab && { index: toStoredDataset(apiTab.dataset) }), }; - const [, searchSourceReferences] = extractReferences(searchSourceValues); - - const sharedAttrs: DiscoverSessionTabAttributes = { - sort: toStoredSort(tab.sort), - columns: toStoredColumns(tab.columns), - grid: toStoredGrid(tab.columns), + const [searchSourceFields, references] = extractReferences(searchSourceValues); + const state: DiscoverSessionTabAttributes = { + sort: toStoredSort(sort), + columns: toStoredColumns(columns), + ...(rowHeight && { rowHeight: toStoredHeight(rowHeight) }), + ...(headerRowHeight && { headerRowHeight: toStoredHeight(headerRowHeight) }), + ...(density && { density }), + grid: toStoredGrid(columns), hideChart: false, - isTextBasedQuery: !('dataset' in tab), - ...('view_mode' in tab && { view_mode: tab.view_mode }), - rowHeight: tab.row_height === 'auto' || tab.row_height === undefined ? -1 : tab.row_height, - headerRowHeight: - tab.header_row_height === 'auto' || tab.header_row_height === undefined - ? -1 - : tab.header_row_height, - density: tab.density, - ...('sample_size' in tab && { sampleSize: tab.sample_size }), - ...('rows_per_page' in tab && { rowsPerPage: tab.rows_per_page }), - kibanaSavedObjectMeta: { searchSourceJSON: JSON.stringify(searchSourceValues) }, - }; - const attributes: SavedSearchByValueAttributes = { - title: apiState.title ?? '', - description: apiState.description ?? '', - ...sharedAttrs, - sort: sharedAttrs.sort as SavedSearchByValueAttributes['sort'], - columns: sharedAttrs.columns as SavedSearchByValueAttributes['columns'], - tabs: [ - { - id: '', // Unused for byValue but required for schema validation - label: '', // Unused for byValue but required for schema validation - attributes: sharedAttrs, - }, - ], - }; - return { - state: { ...state, attributes }, - references: [...references, ...searchSourceReferences], + isTextBasedQuery: !('dataset' in apiTab), + kibanaSavedObjectMeta: { searchSourceJSON: JSON.stringify(searchSourceFields) }, }; + return { state, references }; } export function fromStoredColumns( columns: DiscoverSessionTabAttributes['columns'], - grid: DiscoverSessionTabAttributes['grid'] + grid?: DiscoverSessionTabAttributes['grid'] ): DiscoverSessionTab['columns'] { return columns.map((name) => ({ name, - ...(grid.columns?.[name] && { width: grid.columns?.[name]?.width }), + ...(grid?.columns?.[name] && { width: grid.columns[name]?.width }), })); } @@ -252,10 +326,22 @@ export function fromStoredSort( export function toStoredSort( sort: DiscoverSessionTab['sort'] = [] -): DiscoverSessionTabAttributes['sort'] { +): DiscoverSessionTabAttributes['sort'] & SavedSearchAttributes['sort'] { return sort.map((s) => [s.name, s.direction]); } +export function fromStoredHeight< + T extends DiscoverSessionTab['row_height'] | DiscoverSessionTab['header_row_height'] +>(height: number = 3): T { + return (height === -1 ? 'auto' : height) as T; +} + +export function toStoredHeight( + height: DiscoverSessionTab['row_height'] | DiscoverSessionTab['header_row_height'] +): number { + return typeof height === 'number' ? height : -1; // -1 === 'auto' +} + export function fromStoredDataset( index: SerializedSearchSourceFields['index'] ): DiscoverSessionDataset { diff --git a/src/platform/plugins/shared/discover/server/embeddable/index.ts b/src/platform/plugins/shared/discover/server/embeddable/index.ts index d53dfb81c6bab..ba28365d69276 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/index.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/index.ts @@ -15,6 +15,7 @@ export type { DiscoverSessionClassicTab, DiscoverSessionEsqlTab, DiscoverSessionTab, + DiscoverSessionPanelOverrides, DiscoverSessionEmbeddableByValueState, DiscoverSessionEmbeddableByReferenceState, DiscoverSessionEmbeddableState, diff --git a/src/platform/plugins/shared/discover/server/embeddable/schema.ts b/src/platform/plugins/shared/discover/server/embeddable/schema.ts index 5a95eb30a4290..9dfaaf5f44c7e 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -12,14 +12,12 @@ import { schema } from '@kbn/config-schema'; import { DataGridDensity } from '@kbn/discover-utils'; import { aggregateQuerySchema, querySchema } from '@kbn/es-query-server'; import { - type SerializedTitles, 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 type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; -import type { SerializedTimeRange } from '@kbn/presentation-publishing'; const columnSchema = schema.object({ name: schema.string({ @@ -289,6 +287,111 @@ const dataTableSchema = schema.object( { meta: { id: 'discoverSessionEmbeddableDataTableSchema' } } ); +const panelOverridesSchema = schema.object({ + columns: schema.maybe( + schema.arrayOf(columnSchema, { + maxSize: 100, + defaultValue: [], + meta: { + description: + 'Columns to display in the data table. When set, overrides the referenced saved object (when `discover_session_id` is used) or the inline tab config in `tabs`. If omitted, falls back to the source or to the advanced setting "defaultColumns".', + }, + }) + ), + sort: schema.maybe( + schema.arrayOf(sortSchema, { + maxSize: 100, + defaultValue: [], + meta: { + description: + '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.', + }, + }) + ), + 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( + [ + schema.number({ + min: 1, + max: 20, + }), + schema.literal('auto'), + ], + { + defaultValue: 3, + meta: { + description: + '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.oneOf( + [ + schema.literal(10), + schema.literal(25), + schema.literal(50), + schema.literal(100), + schema.literal(250), + schema.literal(500), + ], + { + 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".', + }, + }) + ), +}); + const classicTabSchema = schema.allOf([ dataTableSchema, dataTableLimitsSchema, @@ -323,13 +426,14 @@ const tabSchema = schema.oneOf([classicTabSchema, esqlTabSchema]); const discoverSessionByValueEmbeddableSchema = schema.allOf([ serializedTitlesSchema, serializedTimeRangeSchema, + panelOverridesSchema, schema.object({ tabs: schema.arrayOf(tabSchema, { minSize: 1, maxSize: 1, meta: { description: - 'Array of tabs for the Discover session embeddable. Currently supports one tab.', + 'Inline tab configuration. Used when no `discover_session_id` is set. Panel-level fields (e.g. `columns`, `sort`) override these when provided. Currently supports one tab.', }, }), }), @@ -338,13 +442,14 @@ const discoverSessionByValueEmbeddableSchema = schema.allOf([ const discoverSessionByReferenceEmbeddableSchema = schema.allOf([ serializedTitlesSchema, serializedTimeRangeSchema, + panelOverridesSchema, schema.object({ discover_session_id: schema.string(), selected_tab_id: schema.maybe( schema.string({ meta: { description: - 'The selected tab in the Discover session. If omitted, defaults to the first tab.', + 'Tab to select from the referenced saved object. If omitted, defaults to the first tab.', }, }) ), @@ -359,9 +464,10 @@ export const discoverSessionEmbeddableSchema = schema.oneOf([ export type DiscoverSessionDataViewReference = TypeOf; export type DiscoverSessionDataViewSpec = TypeOf; export type DiscoverSessionDataset = TypeOf; +export type DiscoverSessionPanelOverrides = TypeOf; export type DiscoverSessionClassicTab = TypeOf; export type DiscoverSessionEsqlTab = TypeOf; -export type DiscoverSessionTab = DiscoverSessionClassicTab | DiscoverSessionEsqlTab; +export type DiscoverSessionTab = TypeOf; export type DiscoverSessionEmbeddableByValueState = TypeOf< typeof discoverSessionByValueEmbeddableSchema @@ -370,6 +476,4 @@ export type DiscoverSessionEmbeddableByReferenceState = TypeOf< typeof discoverSessionByReferenceEmbeddableSchema >; export type DiscoverSessionEmbeddableState = SerializedDrilldowns & - SerializedTitles & - SerializedTimeRange & - (DiscoverSessionEmbeddableByValueState | DiscoverSessionEmbeddableByReferenceState); + TypeOf; diff --git a/src/platform/plugins/shared/discover/server/index.ts b/src/platform/plugins/shared/discover/server/index.ts index 47a830160ae53..1a3b047c537cb 100644 --- a/src/platform/plugins/shared/discover/server/index.ts +++ b/src/platform/plugins/shared/discover/server/index.ts @@ -47,6 +47,7 @@ export type { DiscoverSessionClassicTab, DiscoverSessionEsqlTab, DiscoverSessionTab, + DiscoverSessionPanelOverrides, DiscoverSessionEmbeddableByValueState, DiscoverSessionEmbeddableByReferenceState, DiscoverSessionEmbeddableState, From 712e8b86a9e995180e6357574765d527c92e5a36 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Thu, 5 Mar 2026 16:15:43 -0700 Subject: [PATCH 06/33] =?UTF-8?q?centralize=20search=20embeddable=20stored?= =?UTF-8?q?=E2=86=94panel=20state=20transforms=20and=20use=20in=20serializ?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/embeddable/transform_utils.test.ts | 191 +++++++++++++++++- .../common/embeddable/transform_utils.ts | 118 +++++------ .../discover/public/embeddable/constants.ts | 4 +- .../get_search_embeddable_factory.test.tsx | 14 +- .../utils/serialization_utils.test.ts | 13 +- .../embeddable/utils/serialization_utils.ts | 4 +- 6 files changed, 262 insertions(+), 82 deletions(-) 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 index 9f2409836e57f..6d440d7fcfdd6 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -18,6 +18,7 @@ import { fromStoredDataset, fromStoredHeight, fromStoredRuntimeFields, + fromStoredSearchEmbeddableState, fromStoredSort, fromStoredTab, isByReferenceDiscoverSessionEmbeddableState, @@ -29,12 +30,14 @@ import { toStoredGrid, toStoredHeight, toStoredRuntimeFields, + toStoredSearchEmbeddableState, toStoredSort, toStoredTab, } from './transform_utils'; import type { StoredSearchEmbeddableByReferenceState, StoredSearchEmbeddableByValueState, + StoredSearchEmbeddableState, } from './types'; import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; import { SavedSearchType } from '@kbn/saved-search-plugin/common'; @@ -364,7 +367,7 @@ describe('search embeddable transform utils', () => { title: 'My Search', description: 'My description', time_range: { from: 'now-15m', to: 'now' }, - grid: { columns: {} }, + grid: {}, }); }); }); @@ -533,12 +536,185 @@ describe('search embeddable transform utils', () => { }); }); - it('returns empty columns object when columns is empty', () => { - expect(toStoredGrid([])).toEqual({ columns: {} }); + it('returns empty object when columns is empty (no columns with width)', () => { + expect(toStoredGrid([])).toEqual({}); + }); + + it('returns empty object when columns is undefined (default)', () => { + expect(toStoredGrid()).toEqual({}); }); - it('returns empty columns object when columns is undefined (default)', () => { - expect(toStoredGrid()).toEqual({ columns: {} }); + it('returns empty object when no column has width', () => { + const columns = [{ name: 'message' }, { name: '@timestamp' }]; + expect(toStoredGrid(columns)).toEqual({}); + }); + }); + + describe('fromStoredSearchEmbeddableState', () => { + 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 = fromStoredSearchEmbeddableState(storedState); + expect(result).toEqual({ + sort: [{ name: '@timestamp', direction: 'desc' }], + columns: [ + { name: 'message', width: 100 }, + { name: '@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 = fromStoredSearchEmbeddableState(storedState); + expect(result).toEqual({ + sort: [{ name: '@timestamp', direction: 'desc' }], + columns: [{ name: '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 = fromStoredSearchEmbeddableState(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 = fromStoredSearchEmbeddableState(storedState); + expect(result.row_height).toBe('auto'); + expect(result.header_row_height).toBe('auto'); + }); + }); + + describe('toStoredSearchEmbeddableState', () => { + it('converts panel overrides with all fields to stored state', () => { + const apiState = { + sort: [{ name: '@timestamp', direction: 'desc' as const }], + columns: [{ name: 'message' }, { name: '@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 = toStoredSearchEmbeddableState(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 }], + columns: [{ name: 'message' }], + }; + const result = toStoredSearchEmbeddableState(apiState); + expect(result).toEqual({ + sort: [['@timestamp', 'desc']], + columns: ['message'], + grid: {}, + }); + 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 = toStoredSearchEmbeddableState(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 = toStoredSearchEmbeddableState(apiState); + expect(result.rowHeight).toBe(5); + expect(result.headerRowHeight).toBe(2); + }); + + it('round-trips with fromStoredSearchEmbeddableState', () => { + 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 = fromStoredSearchEmbeddableState(storedState); + const back = toStoredSearchEmbeddableState(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); }); }); @@ -938,10 +1114,7 @@ describe('search embeddable transform utils', () => { references ); expect(result.sort).toEqual([{ name: '@timestamp', direction: 'desc' }]); - expect(result.columns).toEqual([ - { name: 'message' }, - { name: '@timestamp', width: 200 }, - ]); + expect(result.columns).toEqual([{ name: 'message' }, { name: '@timestamp', width: 200 }]); expect(result.row_height).toBe('auto'); expect(result.header_row_height).toBe('auto'); expect(result.density).toBe(DataGridDensity.COMPACT); diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts index 5aa13f2476ff9..8e7c633988d54 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -27,6 +27,7 @@ import type { DiscoverSessionEmbeddableByReferenceState, DiscoverSessionEmbeddableByValueState, DiscoverSessionEmbeddableState, + DiscoverSessionPanelOverrides, DiscoverSessionTab, } from '../../server'; import type { @@ -87,15 +88,7 @@ export function byReferenceSavedSearchToDiscoverSessionEmbeddableState( } = storedState; return { ...otherAttrs, - ...(sort && { sort: fromStoredSort(sort) }), - ...(columns && { columns: fromStoredColumns(columns, grid) }), - ...(rowHeight && { row_height: fromStoredHeight(rowHeight) }), - ...(sampleSize && { sample_size: sampleSize }), - ...(rowsPerPage && { - rows_per_page: rowsPerPage as DiscoverSessionEmbeddableState['rows_per_page'], - }), - ...(headerRowHeight && { header_row_height: fromStoredHeight(headerRowHeight) }), - ...(density && { density }), + ...fromStoredSearchEmbeddableState(storedState), discover_session_id: savedObjectRef.id, selected_tab_id: undefined, // Waiting on https://github.com/elastic/kibana/pull/252311 }; @@ -113,10 +106,10 @@ export function byReferenceDiscoverSessionToSavedSearchEmbeddableState( const { sort, columns, - row_height: rowHeight, - sample_size: sampleSize, - rows_per_page: rowsPerPage, - header_row_height: headerRowHeight, + row_height, + sample_size, + rows_per_page, + header_row_height, density, discover_session_id, selected_tab_id, @@ -124,14 +117,7 @@ export function byReferenceDiscoverSessionToSavedSearchEmbeddableState( } = apiState; const state: StoredSearchEmbeddableByReferenceState = { ...otherAttrs, - ...(sort && { sort: toStoredSort(sort) }), - ...(columns && { columns: toStoredColumns(columns) }), - ...(rowHeight && { rowHeight: toStoredHeight(rowHeight) }), - ...(sampleSize && { sampleSize }), - ...(rowsPerPage && { rowsPerPage }), - ...(headerRowHeight && { headerRowHeight: toStoredHeight(headerRowHeight) }), - ...(density && { density }), - grid: toStoredGrid(columns), + ...toStoredSearchEmbeddableState(apiState), }; return { state, @@ -159,15 +145,7 @@ export function byValueSavedSearchToDiscoverSessionEmbeddableState( const apiTab = fromStoredTab(tab.attributes, references); return { ...otherAttrs, - ...(sort && { sort: fromStoredSort(sort) }), - ...(columns && { columns: fromStoredColumns(columns, grid) }), - ...(rowHeight && { row_height: fromStoredHeight(rowHeight) }), - ...(sampleSize && { sample_size: sampleSize }), - ...(rowsPerPage && { - rows_per_page: rowsPerPage as DiscoverSessionEmbeddableState['rows_per_page'], - }), - ...(headerRowHeight && { header_row_height: fromStoredHeight(headerRowHeight) }), - ...(density && { density }), + ...fromStoredSearchEmbeddableState(storedState), tabs: [apiTab], }; } @@ -179,10 +157,10 @@ export function byValueDiscoverSessionToSavedSearchEmbeddableState( const { sort, columns, - row_height: rowHeight, - sample_size: sampleSize, - rows_per_page: rowsPerPage, - header_row_height: headerRowHeight, + row_height, + sample_size, + rows_per_page, + header_row_height, density, tabs: [apiTab], ...otherAttrs @@ -190,14 +168,7 @@ export function byValueDiscoverSessionToSavedSearchEmbeddableState( const { state: tabAttributes, references: tabReferences } = toStoredTab(apiTab); const state: StoredSearchEmbeddableByValueState = { ...otherAttrs, - ...(sort && { sort: toStoredSort(sort) }), - ...(columns && { columns: toStoredColumns(columns) }), - ...(rowHeight && { rowHeight: toStoredHeight(rowHeight) }), - ...(sampleSize && { sampleSize }), - ...(rowsPerPage && { rowsPerPage }), - ...(headerRowHeight && { headerRowHeight: toStoredHeight(headerRowHeight) }), - ...(density && { density }), - grid: toStoredGrid(columns), + ...toStoredSearchEmbeddableState(apiState), attributes: { ...tabAttributes, sort: tabAttributes.sort as SavedSearchAttributes['sort'], @@ -224,20 +195,16 @@ export function fromStoredTab( ): DiscoverSessionTab { const { sort, - columns, - rowHeight, sampleSize, rowsPerPage, headerRowHeight, density, - grid, viewMode, kibanaSavedObjectMeta: { searchSourceJSON }, } = tab; const apiTab = { + ...fromStoredSearchEmbeddableState(tab), sort: fromStoredSort(sort), - ...(columns && { columns: fromStoredColumns(columns, grid) }), - ...(rowHeight && { row_height: fromStoredHeight(rowHeight) }), header_row_height: fromStoredHeight(headerRowHeight)!, density: density ?? DataGridDensity.COMPACT, }; @@ -262,13 +229,7 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { state: DiscoverSessionTabAttributes; references: SavedObjectReference[]; } { - const { - sort, - columns, - row_height: rowHeight, - header_row_height: headerRowHeight, - density, - } = apiTab; + const { sort, columns } = apiTab; const searchSourceValues: SerializedSearchSourceFields = { query: apiTab.query, ...('filters' in apiTab && { filter: toStoredFilters(apiTab.filters) }), @@ -276,11 +237,9 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { }; const [searchSourceFields, references] = extractReferences(searchSourceValues); const state: DiscoverSessionTabAttributes = { + ...toStoredSearchEmbeddableState(apiTab), sort: toStoredSort(sort), columns: toStoredColumns(columns), - ...(rowHeight && { rowHeight: toStoredHeight(rowHeight) }), - ...(headerRowHeight && { headerRowHeight: toStoredHeight(headerRowHeight) }), - ...(density && { density }), grid: toStoredGrid(columns), hideChart: false, isTextBasedQuery: !('dataset' in apiTab), @@ -289,6 +248,49 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { return { state, references }; } +export function fromStoredSearchEmbeddableState( + storedState: StoredSearchEmbeddableState | DiscoverSessionTabAttributes +): DiscoverSessionPanelOverrides { + const { sort, columns, rowHeight, sampleSize, rowsPerPage, headerRowHeight, density, grid } = + storedState; + return { + ...(sort && { sort: fromStoredSort(sort) }), + ...(columns && { columns: fromStoredColumns(columns, grid) }), + ...(rowHeight && { row_height: fromStoredHeight(rowHeight) }), + ...(sampleSize && { sample_size: sampleSize }), + ...(rowsPerPage && { + rows_per_page: rowsPerPage as DiscoverSessionEmbeddableState['rows_per_page'], + }), + ...(headerRowHeight && { header_row_height: fromStoredHeight(headerRowHeight) }), + ...(density && { density }), + }; +} + +export function toStoredSearchEmbeddableState( + apiState: DiscoverSessionPanelOverrides +): StoredSearchEmbeddableState { + const { + sort, + columns, + row_height: rowHeight, + sample_size: sampleSize, + rows_per_page: rowsPerPage, + header_row_height: headerRowHeight, + density, + } = apiState; + const grid = toStoredGrid(columns); + return { + ...(sort && { sort: toStoredSort(sort) }), + ...(columns && { columns: toStoredColumns(columns) }), + ...(rowHeight && { rowHeight: toStoredHeight(rowHeight) }), + ...(sampleSize && { sampleSize }), + ...(rowsPerPage && { rowsPerPage }), + ...(headerRowHeight && { headerRowHeight: toStoredHeight(headerRowHeight) }), + ...(density && { density }), + ...(grid && { grid }), + }; +} + export function fromStoredColumns( columns: DiscoverSessionTabAttributes['columns'], grid?: DiscoverSessionTabAttributes['grid'] @@ -311,7 +313,7 @@ export function toStoredGrid( const entries = columns ?.filter((c) => c.width != null) // Only persist columns with a width defined .map(({ name, width }) => [name, { width }]); - return { columns: Object.fromEntries(entries) }; + return entries.length ? { columns: Object.fromEntries(entries) } : {}; } export function fromStoredSort( diff --git a/src/platform/plugins/shared/discover/public/embeddable/constants.ts b/src/platform/plugins/shared/discover/public/embeddable/constants.ts index 916b06ff72a9a..7c954aedb0b6f 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/constants.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/constants.ts @@ -18,10 +18,10 @@ export const ACTION_VIEW_SAVED_SEARCH = 'ACTION_VIEW_SAVED_SEARCH'; 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_search_embeddable_factory.test.tsx b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx index 0cd1132eb7304..8950c23ebe7ed 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 @@ -124,7 +124,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' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -159,7 +159,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' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -185,7 +185,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' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -224,7 +224,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' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -249,7 +249,7 @@ describe('saved search embeddable', () => { }; await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { savedObjectId: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -276,7 +276,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' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -305,7 +305,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' }, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, 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 8f04fd1d79603..14ee50de6b8bf 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 @@ -16,6 +16,7 @@ import type { DiscoverSessionEmbeddableByReferenceState, DiscoverSessionEmbeddableByValueState, } from '../../../server'; +import type { SortOrder } from '@kbn/saved-search-plugin/public'; import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; import { DataGridDensity } from '@kbn/discover-utils'; @@ -150,7 +151,7 @@ describe('Serialization utils', () => { description: 'My description', discover_session_id: 'savedSearch', selected_tab_id: undefined, - sort: [['order_date', 'asc']], + sort: [{ name: 'order_date', direction: 'asc' }], }; const deserializedState = await deserializeState({ @@ -165,6 +166,7 @@ describe('Serialization utils', () => { describe('serialize state', () => { test('by value', () => { + const sort: SortOrder[] = [['order_date', 'desc']]; const searchSource = createSearchSourceMock({ index: dataViewMock, }); @@ -172,7 +174,7 @@ describe('Serialization utils', () => { title: 'test1', description: 'description', columns: ['_source'], - sort: [['order_date', 'desc']], + sort, grid: {}, hideChart: false, sampleSize: 100, @@ -210,15 +212,15 @@ describe('Serialization utils', () => { }); describe('by reference', () => { + const sort: SortOrder[] = [['order_date', 'desc']]; const searchSource = createSearchSourceMock({ index: dataViewMock, }); - const savedSearch = { title: 'test1', description: 'description', columns: ['_source'], - sort: [['order_date', 'desc']], + sort, grid: {}, hideChart: false, sampleSize: 100, @@ -247,6 +249,7 @@ describe('Serialization utils', () => { }); test('overwrite state', () => { + const sortOverride: SortOrder[] = [['order_date', 'asc']]; const serializedState = serializeState({ uuid, initialState: { @@ -255,7 +258,7 @@ describe('Serialization utils', () => { savedSearch: { ...savedSearch, sampleSize: 500, - sort: [['order_date', 'asc']], + sort: sortOverride, } as Parameters[0]['savedSearch'], serializeTitles: jest.fn(), serializeTimeRange: jest.fn(), 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 bba7df9798fe7..5eafa56b9f828 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 @@ -17,6 +17,7 @@ import { byReferenceSavedSearchToDiscoverSessionEmbeddableState, byValueDiscoverSessionToSavedSearchEmbeddableState, byValueSavedSearchToDiscoverSessionEmbeddableState, + toStoredSearchEmbeddableState, } from '../../../common/embeddable/transform_utils'; import { isByReferenceDiscoverSessionEmbeddableState } from '../../../common'; import { EDITABLE_SAVED_SEARCH_KEYS } from '../../../common/embeddable/constants'; @@ -38,6 +39,7 @@ export const deserializeState = async ({ discoverServices: DiscoverServices; }): Promise => { const panelState = pick(serializedState, EDITABLE_PANEL_KEYS); + const savedObjectOverride = toStoredSearchEmbeddableState(serializedState); if (isByReferenceDiscoverSessionEmbeddableState(serializedState)) { // by reference @@ -45,7 +47,6 @@ export const deserializeState = async ({ const so = await get(serializedState.discover_session_id, true); const rawSavedObjectAttributes = pick(so, EDITABLE_SAVED_SEARCH_KEYS); - const savedObjectOverride = pick(serializedState, EDITABLE_SAVED_SEARCH_KEYS); return { // ignore the time range from the saved object - only global time range + panel time range matter ...omit(so, 'timeRange'), @@ -73,6 +74,7 @@ export const deserializeState = async ({ return { ...savedSearch, ...panelState, + ...savedObjectOverride, nonPersistedDisplayOptions: serializedState.nonPersistedDisplayOptions, }; } From bce00e733fd20d152f6f987e064756b76c7f1433 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Fri, 13 Mar 2026 16:54:53 -0700 Subject: [PATCH 07/33] fix(discover): align embeddable state with by-value / by-reference schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - By-value: persist panel state in tabs[0] only. Transforms merge stored panel overrides into the single tab (stored→API) and read from tabs[0] when building stored state (API→stored). No top-level overrides. - By-reference: persist panel overrides under `overrides`. Transforms read/write overrides instead of top-level sort, columns, row_height, etc. Map selected_tab_id ↔ selectedTabId in stored state. - Deserialization: by-ref uses serializedState.overrides; by-value uses serializedState.tabs?.[0] for panel overrides. - Comparators: restrict to DiscoverSessionEmbeddableState (overrides, selected_tab_id, discover_session_id, tabs). Use deepEquality for overrides and tabs so unsaved-changes badge works for both modes. - Add overrides: {} when adding a panel from library (by-ref). - Tests: add overrides to by-ref fixtures; put sort/columns in overrides; fix transformIn expected state (grid, selectedTabId); use API shape in by-value “references stored on dashboard” test. --- .../search_embeddable_transforms.test.ts | 61 ++---- .../common/embeddable/transform_utils.test.ts | 6 + .../common/embeddable/transform_utils.ts | 33 +-- .../get_search_embeddable_factory.tsx | 30 +-- .../utils/add_panel_from_library.ts | 1 + .../utils/serialization_utils.test.ts | 14 +- .../embeddable/utils/serialization_utils.ts | 4 +- .../discover/server/embeddable/schema.ts | 196 +++++++++--------- 8 files changed, 152 insertions(+), 193 deletions(-) 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 a593f4ee6470b..148e5dc8c3545 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 @@ -54,6 +54,7 @@ describe('searchEmbeddableTransforms', () => { time_range: { from: 'now-15m', to: 'now' }, discover_session_id: 'session-123', selected_tab_id: undefined, + overrides: {}, }); expect(mockDrilldownTransforms.transformOut).toHaveBeenCalledWith(state, references); }); @@ -130,11 +131,6 @@ describe('searchEmbeddableTransforms', () => { type: 'dataView', id: 'data-view-1', }); - expect(result.columns).toEqual(result.tabs[0].columns); - expect(result.sort).toEqual(result.tabs[0].sort); - expect(result.density).toBe(DataGridDensity.COMPACT); - expect(result.header_row_height).toBe('auto'); - expect(result.row_height).toBe('auto'); expect(mockDrilldownTransforms.transformOut).toHaveBeenCalledWith(state, references); }); @@ -169,6 +165,7 @@ describe('searchEmbeddableTransforms', () => { time_range: { from: 'now-15m', to: 'now' }, discover_session_id: 'test-saved-object-id', selected_tab_id: undefined, + overrides: {}, }; const result = @@ -178,6 +175,7 @@ describe('searchEmbeddableTransforms', () => { title: 'Test Search', description: 'Test Description', time_range: { from: 'now-15m', to: 'now' }, + grid: {}, }); expect(result.references).toEqual([ { @@ -196,6 +194,7 @@ describe('searchEmbeddableTransforms', () => { time_range: { from: 'now-1h', to: 'now' }, discover_session_id: 'session-456', selected_tab_id: 'tab-1', + overrides: {}, }; const result = @@ -205,6 +204,8 @@ describe('searchEmbeddableTransforms', () => { title: 'My Search', description: 'My description', time_range: { from: 'now-1h', to: 'now' }, + grid: {}, + selectedTabId: 'tab-1', }); expect(result.references).toEqual([ { @@ -253,49 +254,31 @@ describe('searchEmbeddableTransforms', () => { 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, - 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, - sampleSize: 100, - isTextBasedQuery: false, - }, - }, - ], - references: [dataViewRef], - }, + const apiState: DiscoverSessionEmbeddableByValueState = { title: 'Panel Title', + tabs: [ + { + columns: [{ name: '_source' }], + sort: [], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + density: DataGridDensity.COMPACT, + header_row_height: 3, + row_height: 3, + query: { language: 'kuery', query: '' }, + filters: [], + dataset: { type: 'dataView', id: 'data-view-id-123' }, + }, + ], }; const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(serializedState); + getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(apiState); expect(result.references).toContainEqual(dataViewRef); expect((result.state as StoredSearchEmbeddableByValueState).attributes).not.toHaveProperty( 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 index 6d440d7fcfdd6..9e39f3f25b5b7 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -97,6 +97,7 @@ describe('search embeddable transform utils', () => { time_range: { from: 'now-15m', to: 'now' }, discover_session_id: 'session-123', selected_tab_id: undefined, + overrides: {}, }; expect(isByReferenceDiscoverSessionEmbeddableState(state)).toBe(true); }); @@ -140,6 +141,7 @@ describe('search embeddable transform utils', () => { time_range: { from: 'now-15m', to: 'now' }, discover_session_id: 'session-123', selected_tab_id: undefined, + overrides: {}, }); }); @@ -199,6 +201,7 @@ describe('search embeddable transform utils', () => { time_range: { from: 'now-15m', to: 'now' }, discover_session_id: 'session-456', selected_tab_id: undefined, + overrides: {}, }; const { state, references } = discoverSessionToSavedSearchEmbeddableState(apiState); expect(references).toContainEqual({ @@ -342,6 +345,7 @@ describe('search embeddable transform utils', () => { time_range: { from: 'now-15m', to: 'now' }, discover_session_id: 'session-123', selected_tab_id: undefined, + overrides: {}, }); }); }); @@ -354,6 +358,7 @@ describe('search embeddable transform utils', () => { time_range: { from: 'now-15m', to: 'now' }, discover_session_id: 'session-456', selected_tab_id: 'tab-1', + overrides: {}, }; const result = byReferenceDiscoverSessionToSavedSearchEmbeddableState(apiState); expect(result.references).toEqual([ @@ -368,6 +373,7 @@ describe('search embeddable transform utils', () => { description: 'My description', time_range: { from: 'now-15m', to: 'now' }, grid: {}, + selectedTabId: 'tab-1', }); }); }); diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts index 0beb5e3075441..6ea837fef29a6 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -89,9 +89,9 @@ export function byReferenceSavedSearchToDiscoverSessionEmbeddableState( } = storedState; return { ...otherAttrs, - ...fromStoredSearchEmbeddableState(storedState), discover_session_id: savedObjectRef.id, selected_tab_id: selectedTabId, + overrides: fromStoredSearchEmbeddableState(storedState), }; } @@ -104,21 +104,11 @@ export function byReferenceDiscoverSessionToSavedSearchEmbeddableState( type: SavedSearchType, id: apiState.discover_session_id, }; - const { - sort, - columns, - row_height, - sample_size, - rows_per_page, - header_row_height, - density, - discover_session_id, - selected_tab_id, - ...otherAttrs - } = apiState; + const { discover_session_id, selected_tab_id, overrides, ...otherAttrs } = apiState; const state: StoredSearchEmbeddableByReferenceState = { ...otherAttrs, - ...toStoredSearchEmbeddableState(apiState), + ...toStoredSearchEmbeddableState(overrides ?? {}), + ...(selected_tab_id != null && { selectedTabId: selected_tab_id }), }; return { state, @@ -144,10 +134,10 @@ export function byValueSavedSearchToDiscoverSessionEmbeddableState( } = storedState; const [tab] = attributes.tabs ?? extractTabs(attributes).tabs; const apiTab = fromStoredTab(tab.attributes, references); + const panelOverrides = fromStoredSearchEmbeddableState(storedState); return { ...otherAttrs, - ...fromStoredSearchEmbeddableState(storedState), - tabs: [apiTab], + tabs: [{ ...apiTab, ...panelOverrides }], }; } @@ -156,20 +146,13 @@ export function byValueDiscoverSessionToSavedSearchEmbeddableState( references: SavedObjectReference[] = [] ): { state: StoredSearchEmbeddableByValueState; references: SavedObjectReference[] } { const { - sort, - columns, - row_height, - sample_size, - rows_per_page, - header_row_height, - density, tabs: [apiTab], ...otherAttrs } = apiState; const { state: tabAttributes, references: tabReferences } = toStoredTab(apiTab); const state: StoredSearchEmbeddableByValueState = { ...otherAttrs, - ...toStoredSearchEmbeddableState(apiState), + ...toStoredSearchEmbeddableState(apiTab), attributes: { ...tabAttributes, sort: tabAttributes.sort as SavedSearchAttributes['sort'], @@ -260,7 +243,7 @@ export function fromStoredSearchEmbeddableState( ...(rowHeight && { row_height: fromStoredHeight(rowHeight) }), ...(sampleSize && { sample_size: sampleSize }), ...(rowsPerPage && { - rows_per_page: rowsPerPage as DiscoverSessionEmbeddableState['rows_per_page'], + rows_per_page: rowsPerPage as DiscoverSessionPanelOverrides['rows_per_page'], }), ...(headerRowHeight && { header_row_height: fromStoredHeight(headerRowHeight) }), ...(density && { density }), 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 ee3791ba40864..0c33b301f9060 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 @@ -154,37 +154,17 @@ export const getSearchEmbeddableFactory = ({ inlineEditingApi.anyStateChange$ ), getComparators: () => { + const isByValue = !savedObjectId$.getValue(); const isDeleted = isSelectedTabDeleted(selectedTabId$.getValue()); const shouldSkipTabComparators = isDeleted || inlineEditingApi.isEditing(); - return { ...drilldownsManager.comparators, ...titleComparators, ...timeRangeComparators, - ...searchEmbeddable.comparators, - // While the selected tab is missing or inline editing is in progress, - // skip tab-dependent comparators so unsaved-changes badges don't appear - // until the user explicitly applies a tab change. - ...(shouldSkipTabComparators - ? Object.fromEntries( - Object.keys(searchEmbeddable.comparators).map((k) => [k, 'skip']) - ) - : {}), - selectedTabId: shouldSkipTabComparators ? 'skip' : 'referenceEquality', - attributes: 'skip', - breakdownField: 'skip', - hideAggregatedPreview: 'skip', - hideChart: 'skip', - isTextBasedQuery: 'skip', - kibanaSavedObjectMeta: 'skip', - nonPersistedDisplayOptions: 'skip', - refreshInterval: 'skip', - savedObjectId: 'skip', - timeRestore: 'skip', - usesAdHocDataView: 'skip', - controlGroupJson: 'skip', - visContext: 'skip', - tabs: 'skip', + discover_session_id: 'skip', + selected_tab_id: shouldSkipTabComparators ? 'skip' : 'referenceEquality', + overrides: shouldSkipTabComparators ? 'skip' : 'deepEquality', + tabs: !isByValue || shouldSkipTabComparators ? 'skip' : 'deepEquality', }; }, onReset: async (lastSaved) => { 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 2afa334d211fc..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 @@ -36,6 +36,7 @@ export const addPanelFromLibrary: (...params: OnAddParams) => Promise = as panelType: SEARCH_EMBEDDABLE_TYPE, serializedState: { 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 1455abdad4b94..92a320b61dcfb 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 @@ -155,6 +155,7 @@ describe('Serialization utils', () => { description: 'My description', discover_session_id: 'savedSearch', selected_tab_id: undefined, + overrides: {}, }; const deserializedState = await deserializeState({ @@ -179,7 +180,7 @@ describe('Serialization utils', () => { description: 'My description', discover_session_id: 'savedSearch', selected_tab_id: undefined, - sort: [{ name: 'order_date', direction: 'asc' }], + overrides: { sort: [{ name: 'order_date', direction: 'asc' }] }, }; const deserializedState = await deserializeState({ @@ -212,6 +213,7 @@ describe('Serialization utils', () => { title: 'test panel title', discover_session_id: 'savedSearch', selected_tab_id: 'tab-2', + overrides: {}, }; const deserializedState = await deserializeState({ @@ -241,9 +243,10 @@ describe('Serialization utils', () => { title: 'test panel title', discover_session_id: 'savedSearch', selected_tab_id: 'deleted-tab-id', - // Stale overrides from the deleted tab - columns: [{ name: 'stale-col-a' }], - sort: [{ name: 'stale_field', direction: 'asc' }], + overrides: { + columns: [{ name: 'stale-col-a' }], + sort: [{ name: 'stale_field', direction: 'asc' }], + }, }; const deserializedState = await deserializeState({ @@ -272,8 +275,7 @@ describe('Serialization utils', () => { title: 'test panel title', discover_session_id: 'savedSearch', selected_tab_id: 'tab-2', - // Dashboard override for columns on top of tab-2 - columns: [{ name: 'custom-col' }], + overrides: { columns: [{ name: 'custom-col' }] }, }; const deserializedState = await deserializeState({ 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 0cb8553283429..6d67074389cdd 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 @@ -46,9 +46,9 @@ export const deserializeState = async ({ discoverServices: DiscoverServices; }): Promise => { const panelState = pick(serializedState, EDITABLE_PANEL_KEYS); - const savedObjectOverride = toStoredSearchEmbeddableState(serializedState); if (isByReferenceDiscoverSessionEmbeddableState(serializedState)) { + const savedObjectOverride = toStoredSearchEmbeddableState(serializedState.overrides ?? {}); // by reference const { getDiscoverSession } = discoverServices.savedSearch; const session = await getDiscoverSession(serializedState.discover_session_id); @@ -80,6 +80,8 @@ export const deserializeState = async ({ }; } else { // by value + const [tab] = serializedState.tabs; + const savedObjectOverride = toStoredSearchEmbeddableState(tab ?? {}); const { byValueToSavedSearch } = discoverServices.savedSearch; const { state: storedState, references } = diff --git a/src/platform/plugins/shared/discover/server/embeddable/schema.ts b/src/platform/plugins/shared/discover/server/embeddable/schema.ts index 9dfaaf5f44c7e..ef611e6baca2c 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -287,110 +287,113 @@ const dataTableSchema = schema.object( { meta: { id: 'discoverSessionEmbeddableDataTableSchema' } } ); -const panelOverridesSchema = schema.object({ - columns: schema.maybe( - schema.arrayOf(columnSchema, { - maxSize: 100, - defaultValue: [], - meta: { - description: - 'Columns to display in the data table. When set, overrides the referenced saved object (when `discover_session_id` is used) or the inline tab config in `tabs`. If omitted, falls back to the source or to the advanced setting "defaultColumns".', - }, - }) - ), - sort: schema.maybe( - schema.arrayOf(sortSchema, { - maxSize: 100, - defaultValue: [], - meta: { - description: - '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.', - }, - }) - ), - 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, +const panelOverridesSchema = schema.object( + { + columns: schema.maybe( + schema.arrayOf(columnSchema, { + maxSize: 100, + defaultValue: [], 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.', + 'Columns to display in the data table. When set, overrides the referenced saved object (when `discover_session_id` is used) or the inline tab config in `tabs`. If omitted, falls back to the source or to the advanced setting "defaultColumns".', }, - } - ) - ), - row_height: schema.maybe( - schema.oneOf( - [ - schema.number({ - min: 1, - max: 20, - }), - schema.literal('auto'), - ], - { - defaultValue: 3, + }) + ), + sort: schema.maybe( + schema.arrayOf(sortSchema, { + maxSize: 100, + defaultValue: [], meta: { description: - '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".', + '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.', }, - } - ) - ), - 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, + }) + ), + 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( + [ + schema.number({ + min: 1, + max: 20, + }), + schema.literal('auto'), + ], + { + defaultValue: 3, + meta: { + description: + '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.oneOf( + [ + schema.literal(10), + schema.literal(25), + schema.literal(50), + schema.literal(100), + schema.literal(250), + schema.literal(500), + ], + { + 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 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".', + '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".', }, - } - ) - ), - 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".', - }, - }) - ), -}); + }) + ), + }, + { defaultValue: {} } +); const classicTabSchema = schema.allOf([ dataTableSchema, @@ -426,7 +429,6 @@ const tabSchema = schema.oneOf([classicTabSchema, esqlTabSchema]); const discoverSessionByValueEmbeddableSchema = schema.allOf([ serializedTitlesSchema, serializedTimeRangeSchema, - panelOverridesSchema, schema.object({ tabs: schema.arrayOf(tabSchema, { minSize: 1, @@ -442,7 +444,6 @@ const discoverSessionByValueEmbeddableSchema = schema.allOf([ const discoverSessionByReferenceEmbeddableSchema = schema.allOf([ serializedTitlesSchema, serializedTimeRangeSchema, - panelOverridesSchema, schema.object({ discover_session_id: schema.string(), selected_tab_id: schema.maybe( @@ -453,6 +454,7 @@ const discoverSessionByReferenceEmbeddableSchema = schema.allOf([ }, }) ), + overrides: panelOverridesSchema, }), ]); From 901c75ec8b3d1e0ba90113fc85c6dcec27f4855b Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Mon, 16 Mar 2026 15:43:50 -0700 Subject: [PATCH 08/33] FIx logs stream embeddable --- ...et_legacy_log_stream_embeddable_factory.ts | 59 +++++++------------ 1 file changed, 22 insertions(+), 37 deletions(-) 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..691710f36f784 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 @@ -7,10 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { SavedSearch } from '@kbn/saved-search-plugin/public'; -import type { DataView } from '@kbn/data-views-plugin/common'; -import { getAllLogsDataViewSpec } from '@kbn/discover-utils/src'; -import { toSavedSearchAttributes } from '@kbn/saved-search-plugin/common'; +import { DataGridDensity } from '@kbn/discover-utils'; +import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; +import type { DiscoverSessionEmbeddableState } from '../../server'; import { getSearchEmbeddableFactory } from './get_search_embeddable_factory'; import { LEGACY_LOG_STREAM_EMBEDDABLE } from './constants'; @@ -20,40 +19,26 @@ export const getLegacyLogStreamEmbeddableFactory = ( const searchEmbeddableFactory = getSearchEmbeddableFactory({ startServices, discoverServices }); const logStreamEmbeddableFactory: ReturnType = { type: LEGACY_LOG_STREAM_EMBEDDABLE, - buildEmbeddable: async ({ initialState, ...restParams }) => { - const searchSource = await discoverServices.data.search.searchSource.create(); - let fallbackPattern = 'logs-*-*'; - // Given that the logDataAccess service is an optional dependency with discover, we need to check if it exists - if (discoverServices.logsDataAccess) { - fallbackPattern = - await discoverServices.logsDataAccess.services.logSourcesService.getFlattenedLogSources(); - } - - const spec = getAllLogsDataViewSpec({ allLogsIndexPattern: fallbackPattern }); - const dataView: DataView = await discoverServices.data.dataViews.create(spec); - - // Finally assign the data view to the search source - searchSource.setField('index', dataView); - - const savedSearch: SavedSearch = { - title: initialState.title, - description: initialState.description, - timeRange: initialState.time_range, - sort: initialState.sort ?? [], - columns: initialState.columns ?? [], - searchSource, - managed: false, + buildEmbeddable: async ({ initialState: logsInitialState, ...restParams }) => { + const initialState: DiscoverSessionEmbeddableState = { + ...logsInitialState, + tabs: [ + { + dataset: { + type: 'index', + index: discoverServices.logsDataAccess + ? await discoverServices.logsDataAccess.services.logSourcesService.getFlattenedLogSources() + : 'logs-*-*', + time_field: '@timestamp', + }, + sort: [], + density: DataGridDensity.COMPACT, + header_row_height: 3, + filters: [], + view_mode: VIEW_MODE.DOCUMENT_LEVEL, + }, + ], }; - const { searchSourceJSON, references } = searchSource.serialize(); - - initialState = { - ...initialState, - attributes: { - ...toSavedSearchAttributes(savedSearch, searchSourceJSON), - references, - }, - }; - return searchEmbeddableFactory.buildEmbeddable({ initialState, ...restParams }); }, }; From fda13aa1333098f3033ce300c501be4a86e385df Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Tue, 17 Mar 2026 10:34:45 -0700 Subject: [PATCH 09/33] Round trip test & fixes --- .../common/embeddable/transform_utils.test.ts | 106 ++++++++++++++++++ .../common/embeddable/transform_utils.ts | 1 + 2 files changed, 107 insertions(+) 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 index 9e39f3f25b5b7..aa31dc950dc48 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -476,6 +476,112 @@ describe('search embeddable transform utils', () => { }); }); + 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, + 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, + 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 = byValueSavedSearchToDiscoverSessionEmbeddableState(storedState, references); + const { state: reverted, references: revertedRefs } = + byValueDiscoverSessionToSavedSearchEmbeddableState(apiState, []); + + expect(reverted.attributes.title).toBe(storedState.title); + expect(reverted.attributes.description).toBe(storedState.description); + expect(reverted.attributes.tabs).toHaveLength(storedState.attributes.tabs!.length); + + 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 = byReferenceSavedSearchToDiscoverSessionEmbeddableState( + storedState, + references + ); + const { state: reverted, references: revertedRefs } = + byReferenceDiscoverSessionToSavedSearchEmbeddableState(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('fromStoredColumns', () => { it('maps column names to column objects without width when grid has no column widths', () => { const columns = ['message', '@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 index 6ea837fef29a6..9053ff84f422e 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -228,6 +228,7 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { hideChart: false, isTextBasedQuery: !('dataset' in apiTab), kibanaSavedObjectMeta: { searchSourceJSON: JSON.stringify(searchSourceFields) }, + ...('view_mode' in apiTab && { viewMode: apiTab.view_mode }), }; return { state, references }; } From 43a83eb7dfe6e0f7edaf6d72d80db23bf43e5726 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Wed, 18 Mar 2026 18:28:54 -0700 Subject: [PATCH 10/33] Add feature flag --- .../shared/discover/common/constants.ts | 7 ++ .../common/embeddable/get_transform_in.ts | 78 ++++++++++++++-- .../common/embeddable/get_transform_out.ts | 71 +++++++++++++-- .../discover/common/embeddable/index.ts | 6 +- .../search_embeddable_transforms.test.ts | 90 ++++++++++++++----- .../search_embeddable_transforms.ts | 12 +-- .../discover/common/embeddable/type_guards.ts | 33 +++++++ .../discover/common/embeddable/types.ts | 5 +- .../plugins/shared/discover/common/index.ts | 2 +- .../discover/public/__mocks__/services.ts | 1 + .../shared/discover/public/build_services.ts | 5 ++ .../shared/discover/public/constants.ts | 2 + ...et_legacy_log_stream_embeddable_factory.ts | 11 +-- .../get_search_embeddable_factory.tsx | 54 +++++++++-- .../discover/public/embeddable/types.ts | 8 +- .../utils/serialization_utils.test.ts | 86 +++++++++++++++++- .../embeddable/utils/serialization_utils.ts | 55 +++++++++--- .../plugins/shared/discover/public/plugin.tsx | 16 +++- .../plugins/shared/discover/server/plugin.ts | 12 ++- 19 files changed, 478 insertions(+), 76 deletions(-) create mode 100644 src/platform/plugins/shared/discover/common/embeddable/type_guards.ts diff --git a/src/platform/plugins/shared/discover/common/constants.ts b/src/platform/plugins/shared/discover/common/constants.ts index ba6712ccdf606..bd821eb52d676 100644 --- a/src/platform/plugins/shared/discover/common/constants.ts +++ b/src/platform/plugins/shared/discover/common/constants.ts @@ -52,3 +52,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/get_transform_in.ts b/src/platform/plugins/shared/discover/common/embeddable/get_transform_in.ts index c436a02fb7ed4..d79c9516694e6 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 @@ -8,21 +8,87 @@ */ 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 { + isSearchEmbeddableByReferenceState, + isSearchEmbeddableLegacyPanelState, +} from './type_guards'; import { discoverSessionToSavedSearchEmbeddableState } from './transform_utils'; -import type { DiscoverSessionEmbeddableState } from '../../server'; -import type { StoredSearchEmbeddableState } from './types'; +import type { + SearchEmbeddablePanelApiState, + SearchEmbeddableState, + StoredSearchEmbeddableState, +} from './types'; export { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; export function getTransformIn(transformDrilldownsIn: DrilldownTransforms['transformIn']) { - return function transformIn(state: DiscoverSessionEmbeddableState): { + 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) + : discoverSessionToSavedSearchEmbeddableState(state, references); + }; +} + +function legacyTransformIn( + storedState: SearchEmbeddableState, + drilldownReferences: SavedObjectReference[] = [] +): { state: StoredSearchEmbeddableState; references: SavedObjectReference[] } { + if (isSearchEmbeddableByReferenceState(storedState)) { + const { savedObjectId, ...rest } = storedState; + return { + state: rest, + references: [ + { + name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, + type: SavedSearchType, + id: savedObjectId, + }, + ...drilldownReferences, + ], + }; + } + + // 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; + } + }); - return discoverSessionToSavedSearchEmbeddableState(storedState, drilldownReferences); + const { references = [], ...otherAttrs } = storedState.attributes; + return { + state: { + ...storedState, + attributes: { + ...otherAttrs, + tabs, + }, + }, + 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 c71709e9a3c96..96dc46ac28e20 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,24 +7,83 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { flow } from 'lodash'; -import type { DrilldownTransforms } from '@kbn/embeddable-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 { 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, + SearchEmbeddablePanelApiState, + StoredSearchEmbeddableState, +} from './types'; +import { isSearchEmbeddableByValueState } from './type_guards'; import { savedSearchToDiscoverSessionEmbeddableState } from './transform_utils'; -import type { StoredSearchEmbeddableState } from './types'; -export function getTransformOut(transformDrilldownsOut: DrilldownTransforms['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 savedSearchToDiscoverSessionEmbeddableState(state, references); + return !isEmbeddableTransformsEnabled() + ? legacyTransformOut(state, references) + : savedSearchToDiscoverSessionEmbeddableState(state, references); }; } + +function legacyTransformOut( + state: StoredSearchEmbeddableState, + references: SavedObjectReference[] | undefined +): SearchEmbeddableByReferenceState | SearchEmbeddableByValueState { + 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; + } + + 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; +} diff --git a/src/platform/plugins/shared/discover/common/embeddable/index.ts b/src/platform/plugins/shared/discover/common/embeddable/index.ts index dc1ed713428d6..946c7071fdee3 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/index.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/index.ts @@ -7,7 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { getSearchEmbeddableTransforms } from './search_embeddable_transforms'; +export { + getSearchEmbeddableTransforms, + type SearchEmbeddablePanelApiState, +} from './search_embeddable_transforms'; +export { isSearchEmbeddableLegacyPanelState } from './type_guards'; export { discoverSessionToSavedSearchEmbeddableState, isByReferenceDiscoverSessionEmbeddableState, 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 148e5dc8c3545..1198ceffe3ec1 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 @@ -9,7 +9,11 @@ import type { DrilldownTransforms } from '@kbn/embeddable-plugin/common'; import { getSearchEmbeddableTransforms } from './search_embeddable_transforms'; -import type { StoredSearchEmbeddableByValueState, StoredSearchEmbeddableState } from './types'; +import type { + SearchEmbeddableState, + StoredSearchEmbeddableByValueState, + StoredSearchEmbeddableState, +} from './types'; import type { DiscoverSessionClassicTab, DiscoverSessionEmbeddableByReferenceState, @@ -34,6 +38,9 @@ describe('searchEmbeddableTransforms', () => { jest.clearAllMocks(); }); + const whenEnabled = () => true; + const whenDisabled = () => false; + describe('transformOut', () => { it('converts by-reference stored state to DiscoverSession API shape', () => { const state: StoredSearchEmbeddableState = { @@ -44,10 +51,10 @@ describe('searchEmbeddableTransforms', () => { const references = [ { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-123' }, ]; - const result = getSearchEmbeddableTransforms(mockDrilldownTransforms).transformOut?.( - state, - references - ); + const result = getSearchEmbeddableTransforms( + mockDrilldownTransforms, + whenEnabled + ).transformOut?.(state, references); expect(result).toEqual({ title: 'Test Title', description: 'Test Description', @@ -107,10 +114,10 @@ describe('searchEmbeddableTransforms', () => { id: 'data-view-1', }, ]; - const result = getSearchEmbeddableTransforms(mockDrilldownTransforms).transformOut?.( - state, - references - ) as DiscoverSessionEmbeddableByValueState; + 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); @@ -143,10 +150,10 @@ describe('searchEmbeddableTransforms', () => { const mockReferences = [ { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-xyz' }, ]; - const result = getSearchEmbeddableTransforms(mockDrilldownTransforms).transformOut?.( - state, - mockReferences - ); + const result = getSearchEmbeddableTransforms( + mockDrilldownTransforms, + whenEnabled + ).transformOut?.(state, mockReferences); expect(mockDrilldownTransforms.transformOut).toHaveBeenCalledWith(state, mockReferences); expect(result).toMatchObject({ title: 'Test Title', @@ -168,8 +175,8 @@ describe('searchEmbeddableTransforms', () => { overrides: {}, }; - const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(apiState); + const result = getSearchEmbeddableTransforms(mockDrilldownTransforms, whenEnabled) + .transformIn!(apiState); expect(result.state).toEqual({ title: 'Test Search', @@ -197,8 +204,8 @@ describe('searchEmbeddableTransforms', () => { overrides: {}, }; - const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(apiState); + const result = getSearchEmbeddableTransforms(mockDrilldownTransforms, whenEnabled) + .transformIn!(apiState); expect(result.state).toEqual({ title: 'My Search', @@ -239,8 +246,8 @@ describe('searchEmbeddableTransforms', () => { ], }; - const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(apiState); + const result = getSearchEmbeddableTransforms(mockDrilldownTransforms, whenEnabled) + .transformIn!(apiState); expect(result.references).toContainEqual({ id: 'data-view-1', @@ -277,8 +284,8 @@ describe('searchEmbeddableTransforms', () => { ], }; - const result = - getSearchEmbeddableTransforms(mockDrilldownTransforms).transformIn!(apiState); + const result = getSearchEmbeddableTransforms(mockDrilldownTransforms, whenEnabled) + .transformIn!(apiState); expect(result.references).toContainEqual(dataViewRef); expect((result.state as StoredSearchEmbeddableByValueState).attributes).not.toHaveProperty( @@ -287,4 +294,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 474caa941121f..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,16 +8,18 @@ */ import type { DrilldownTransforms, EmbeddableTransforms } from '@kbn/embeddable-plugin/common'; -import type { DiscoverSessionEmbeddableState } from '../../server'; -import type { 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/type_guards.ts b/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts new file mode 100644 index 0000000000000..38568d1b82921 --- /dev/null +++ b/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts @@ -0,0 +1,33 @@ +/* + * 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 { + SearchEmbeddableByReferenceState, + SearchEmbeddableByValueState, + SearchEmbeddableState, + StoredSearchEmbeddableState, +} from './types'; + +export function isSearchEmbeddableByReferenceState( + state: SearchEmbeddableState | StoredSearchEmbeddableState +): state is SearchEmbeddableByReferenceState { + return 'savedObjectId' in state; +} + +export function isSearchEmbeddableByValueState( + state: StoredSearchEmbeddableState +): state is SearchEmbeddableByValueState { + return 'attributes' in state && typeof state.attributes === 'object' && state.attributes !== null; +} + +export function isSearchEmbeddableLegacyPanelState( + state: SearchEmbeddableState | StoredSearchEmbeddableState +): state is SearchEmbeddableState { + return isSearchEmbeddableByReferenceState(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 3959e9effb421..07b6b38d745df 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/public/__mocks__/services.ts b/src/platform/plugins/shared/discover/public/__mocks__/services.ts index 7a0a2cba12c2f..e378e08ed9a91 100644 --- a/src/platform/plugins/shared/discover/public/__mocks__/services.ts +++ b/src/platform/plugins/shared/discover/public/__mocks__/services.ts @@ -308,6 +308,7 @@ export function createDiscoverServicesMock(): DiscoverServices { discoverFeatureFlags: { getCascadeLayoutEnabled: jest.fn(() => false), getIsEsqlDefault: jest.fn(() => false), + getEmbeddableTransformsEnabled: jest.fn(() => true), }, 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 c8128312a2222..608d1192a18db 100644 --- a/src/platform/plugins/shared/discover/public/build_services.ts +++ b/src/platform/plugins/shared/discover/public/build_services.ts @@ -72,6 +72,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'; @@ -92,6 +93,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 { @@ -205,6 +208,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 af272e378e6f8..d2d376290e556 100644 --- a/src/platform/plugins/shared/discover/public/constants.ts +++ b/src/platform/plugins/shared/discover/public/constants.ts @@ -13,3 +13,5 @@ export const SEARCH_SESSION_ID_QUERY_PARAM = 'searchSessionId'; export const CASCADE_LAYOUT_ENABLED_FEATURE_FLAG_KEY = 'discover.cascadeLayoutEnabled'; export const IS_ESQL_DEFAULT_FEATURE_FLAG_KEY = 'discover.isEsqlDefault'; + +export { EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY } from '../common'; 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 691710f36f784..5cccf7c29b145 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 @@ -7,8 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { DataGridDensity } from '@kbn/discover-utils'; -import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; import type { DiscoverSessionEmbeddableState } from '../../server'; import { getSearchEmbeddableFactory } from './get_search_embeddable_factory'; import { LEGACY_LOG_STREAM_EMBEDDABLE } from './constants'; @@ -20,7 +18,7 @@ export const getLegacyLogStreamEmbeddableFactory = ( const logStreamEmbeddableFactory: ReturnType = { type: LEGACY_LOG_STREAM_EMBEDDABLE, buildEmbeddable: async ({ initialState: logsInitialState, ...restParams }) => { - const initialState: DiscoverSessionEmbeddableState = { + const initialState = { ...logsInitialState, tabs: [ { @@ -31,14 +29,9 @@ export const getLegacyLogStreamEmbeddableFactory = ( : 'logs-*-*', time_field: '@timestamp', }, - sort: [], - density: DataGridDensity.COMPACT, - header_row_height: 3, - filters: [], - view_mode: VIEW_MODE.DOCUMENT_LEVEL, }, ], - }; + } as DiscoverSessionEmbeddableState; return searchEmbeddableFactory.buildEmbeddable({ initialState, ...restParams }); }, }; 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 0c33b301f9060..426f7cc05f333 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 { DiscoverSessionEmbeddableState } from '../../server'; -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'; @@ -59,7 +58,7 @@ export const getSearchEmbeddableFactory = ({ const { save, checkForDuplicateTitle } = discoverServices.savedSearch; const savedSearchEmbeddableFactory: EmbeddableFactory< - DiscoverSessionEmbeddableState, + SearchEmbeddablePanelApiState, SearchEmbeddableApi > = { type: SEARCH_EMBEDDABLE_TYPE, @@ -70,6 +69,9 @@ export const getSearchEmbeddableFactory = ({ parentApi, uuid, }) => { + const embeddableTransformsEnabled = + discoverServices.discoverFeatureFlags.getEmbeddableTransformsEnabled(); + const runtimeState = await deserializeState({ serializedState: initialState, discoverServices, @@ -126,6 +128,7 @@ export const getSearchEmbeddableFactory = ({ serializeDynamicActions: drilldownsManager.getLatestState, savedObjectId, selectedTabId: selectedTabId$.getValue(), + embeddableTransformsEnabled, }); const inlineEditingApi = initializeInlineEditingApi({ @@ -138,7 +141,7 @@ export const getSearchEmbeddableFactory = ({ dataLoading$, }); - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ uuid, parentApi, serializeState: () => serialize(savedObjectId$.getValue()), @@ -154,17 +157,50 @@ export const getSearchEmbeddableFactory = ({ inlineEditingApi.anyStateChange$ ), getComparators: () => { - const isByValue = !savedObjectId$.getValue(); 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, ...timeRangeComparators, - discover_session_id: 'skip', - selected_tab_id: shouldSkipTabComparators ? 'skip' : 'referenceEquality', - overrides: shouldSkipTabComparators ? 'skip' : 'deepEquality', - tabs: !isByValue || shouldSkipTabComparators ? 'skip' : 'deepEquality', + ...searchEmbeddable.comparators, + // While the selected tab is missing or inline editing is in progress, + // skip tab-dependent comparators so unsaved-changes badges don't appear + // until the user explicitly applies a tab change. + ...(shouldSkipTabComparators + ? Object.fromEntries( + Object.keys(searchEmbeddable.comparators).map((k) => [k, 'skip']) + ) + : {}), + selectedTabId: shouldSkipTabComparators ? 'skip' : 'referenceEquality', + attributes: 'skip', + breakdownField: 'skip', + hideAggregatedPreview: 'skip', + hideChart: 'skip', + isTextBasedQuery: 'skip', + kibanaSavedObjectMeta: 'skip', + nonPersistedDisplayOptions: 'skip', + refreshInterval: 'skip', + savedObjectId: 'skip', + timeRestore: 'skip', + usesAdHocDataView: 'skip', + controlGroupJson: 'skip', + visContext: 'skip', + tabs: 'skip', }; }, onReset: async (lastSaved) => { diff --git a/src/platform/plugins/shared/discover/public/embeddable/types.ts b/src/platform/plugins/shared/discover/public/embeddable/types.ts index e5d725768c5bc..500362e5eaf43 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/types.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/types.ts @@ -36,17 +36,19 @@ import type { DataTableColumnsMeta } from '@kbn/unified-data-table'; import type { BehaviorSubject } from 'rxjs'; import type { PublishesWritableDataViews } from '@kbn/presentation-publishing/interfaces/publishes_data_views'; import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; -import type { DiscoverSessionEmbeddableState } from '../../server'; import type { NonPersistedDisplayOptions, + SearchEmbeddablePanelApiState, } from '../../common/embeddable/types'; +export type { SearchEmbeddablePanelApiState }; + /** * Input state accepted by the search embeddable factory. Extends the persisted * session state with optional display options passed by solutions (e.g. APM, Infra) * when using SavedSearchComponent outside of dashboards. These options are not persisted. */ -export type SearchEmbeddableInputState = DiscoverSessionEmbeddableState & { +export type SearchEmbeddableInputState = SearchEmbeddablePanelApiState & { nonPersistedDisplayOptions?: NonPersistedDisplayOptions; }; @@ -92,7 +94,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/serialization_utils.test.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts index 92a320b61dcfb..c98274a599dfa 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 @@ -22,13 +22,26 @@ 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 { SearchEmbeddableByValueState } from '../../../common/embeddable/types'; +import type { + SearchEmbeddableByReferenceState, + SearchEmbeddableByValueState, +} from '../../../common/embeddable/types'; +import type { DiscoverServices } from '../../build_services'; describe('Serialization utils', () => { const uuid = 'mySearchEmbeddable'; const dataViewId = dataViewMock.id ?? 'test-id'; + const discoverServicesLegacy = { + ...discoverServiceMock, + discoverFeatureFlags: { + getCascadeLayoutEnabled: jest.fn(() => false), + getIsEsqlDefault: jest.fn(() => false), + getEmbeddableTransformsEnabled: jest.fn(() => false), + }, + } as unknown as DiscoverServices; + const mockedSavedSearchAttributes: SearchEmbeddableByValueState['attributes'] = { kibanaSavedObjectMeta: { searchSourceJSON: '{"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', @@ -318,6 +331,7 @@ describe('Serialization utils', () => { serializeTitles: jest.fn().mockReturnValue({ title: 'test1', description: 'description' }), serializeTimeRange: jest.fn(), serializeDynamicActions: jest.fn(), + embeddableTransformsEnabled: true, }); expect(serializedState).toMatchObject({ @@ -365,6 +379,7 @@ describe('Serialization utils', () => { serializeTimeRange: jest.fn(), serializeDynamicActions: jest.fn(), savedObjectId: 'test-id', + embeddableTransformsEnabled: true, }); expect(serializedState).toMatchObject({ @@ -390,6 +405,7 @@ describe('Serialization utils', () => { serializeDynamicActions: jest.fn(), savedObjectId: 'test-id', selectedTabId: 'tab-1', + embeddableTransformsEnabled: true, }); // By-reference API shape includes discover_session_id; panel overrides (sampleSize, sort) @@ -411,6 +427,7 @@ describe('Serialization utils', () => { serializeDynamicActions: jest.fn(), savedObjectId: 'test-id', selectedTabId: 'tab-2', + embeddableTransformsEnabled: true, }); expect(serializedState).toMatchObject({ @@ -431,6 +448,7 @@ describe('Serialization utils', () => { serializeDynamicActions: jest.fn(), savedObjectId: 'test-id', selectedTabId: undefined, + embeddableTransformsEnabled: true, }); expect(serializedState).toMatchObject({ @@ -439,4 +457,70 @@ describe('Serialization utils', () => { }); }); }); + + 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 6d67074389cdd..16660a9b3b556 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 @@ -16,12 +16,14 @@ import { toSavedSearchAttributes, } from '@kbn/saved-search-plugin/common'; import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; +import { isSearchEmbeddableLegacyPanelState } from '../../../common/embeddable'; import { byReferenceSavedSearchToDiscoverSessionEmbeddableState, byValueDiscoverSessionToSavedSearchEmbeddableState, byValueSavedSearchToDiscoverSessionEmbeddableState, toStoredSearchEmbeddableState, } from '../../../common/embeddable/transform_utils'; +import type { SearchEmbeddableState } from '../../../common'; import { isByReferenceDiscoverSessionEmbeddableState } from '../../../common'; import { EDITABLE_SAVED_SEARCH_KEYS, @@ -29,14 +31,18 @@ import { } from '../../../common/embeddable/constants'; import type { EditableSavedSearchAttributes, + SearchEmbeddablePanelApiState, StoredSearchEmbeddableByReferenceState, StoredSearchEmbeddableState, } from '../../../common/embeddable/types'; -import type { DiscoverSessionEmbeddableState } from '../../../server'; import type { DiscoverServices } from '../../build_services'; import { EDITABLE_PANEL_KEYS } from '../constants'; import type { SearchEmbeddableInputState, SearchEmbeddableRuntimeState } from '../types'; import { isTabDeleted } from './is_tab_deleted'; +import { + isSearchEmbeddableByReferenceState, + isSearchEmbeddableByValueState, +} from '../../../common/embeddable/type_guards'; export const deserializeState = async ({ serializedState, @@ -47,13 +53,26 @@ export const deserializeState = async ({ }): Promise => { const panelState = pick(serializedState, EDITABLE_PANEL_KEYS); - if (isByReferenceDiscoverSessionEmbeddableState(serializedState)) { - const savedObjectOverride = toStoredSearchEmbeddableState(serializedState.overrides ?? {}); + if ( + (isSearchEmbeddableLegacyPanelState(serializedState) && + isSearchEmbeddableByReferenceState(serializedState)) || + (!isSearchEmbeddableLegacyPanelState(serializedState) && + isByReferenceDiscoverSessionEmbeddableState(serializedState)) + ) { // by reference + const savedObjectOverride = isSearchEmbeddableLegacyPanelState(serializedState) + ? pick(serializedState, EDITABLE_SAVED_SEARCH_KEYS) + : toStoredSearchEmbeddableState(serializedState.overrides ?? {}); + const savedObjectId = isSearchEmbeddableLegacyPanelState(serializedState) + ? serializedState.savedObjectId + : serializedState.discover_session_id; + const selectedTabId = isSearchEmbeddableLegacyPanelState(serializedState) + ? serializedState.selectedTabId + : serializedState.selected_tab_id; + const { getDiscoverSession } = discoverServices.savedSearch; - const session = await getDiscoverSession(serializedState.discover_session_id); + const session = await getDiscoverSession(savedObjectId); - const selectedTabId = serializedState.selected_tab_id; const selectedTab = selectedTabId ? session.tabs.find((t) => t.id === selectedTabId) : undefined; @@ -69,7 +88,7 @@ export const deserializeState = async ({ return { ...runtimeSavedSearchState, - savedObjectId: serializedState.discover_session_id, + savedObjectId, savedObjectTitle: session.title, savedObjectDescription: session.description, selectedTabId: resolvedSelectedTabId, @@ -78,8 +97,18 @@ export const deserializeState = async ({ // Overwrite SO state with dashboard state for title, description, etc. ...panelState, }; + } else if (isSearchEmbeddableByValueState(serializedState)) { + const { byValueToSavedSearch } = discoverServices.savedSearch; + const savedSearch = await byValueToSavedSearch(serializedState, true); + + const { tabs, ...savedSearchWithoutTabs } = savedSearch; + + return { + ...savedSearchWithoutTabs, + ...panelState, + nonPersistedDisplayOptions: serializedState.nonPersistedDisplayOptions, + }; } else { - // by value const [tab] = serializedState.tabs; const savedObjectOverride = toStoredSearchEmbeddableState(tab ?? {}); const { byValueToSavedSearch } = discoverServices.savedSearch; @@ -111,6 +140,7 @@ export const serializeState = ({ serializeDynamicActions, savedObjectId, selectedTabId, + embeddableTransformsEnabled, }: { uuid: string; initialState: SearchEmbeddableRuntimeState; @@ -120,7 +150,8 @@ export const serializeState = ({ serializeDynamicActions: () => SerializedDrilldowns; savedObjectId?: string; selectedTabId?: string; -}): DiscoverSessionEmbeddableState => { + embeddableTransformsEnabled: boolean; +}): SearchEmbeddablePanelApiState => { const searchSource = savedSearch.searchSource; const searchSourceJSON = JSON.stringify(searchSource.getSerializedFields()); const savedSearchAttributes = toSavedSearchAttributes(savedSearch, searchSourceJSON); @@ -159,7 +190,9 @@ export const serializeState = ({ const refs = [ { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: savedObjectId }, ]; - return byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedByRef, refs); + return embeddableTransformsEnabled + ? byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedByRef, refs) + : ({ ...storedByRef, savedObjectId } as SearchEmbeddableState); } const stored: StoredSearchEmbeddableState = { @@ -168,5 +201,7 @@ export const serializeState = ({ ...serializeDynamicActions?.(), attributes: savedSearchAttributes, }; - return byValueSavedSearchToDiscoverSessionEmbeddableState(stored, []); + return embeddableTransformsEnabled + ? byValueSavedSearchToDiscoverSessionEmbeddableState(stored, []) + : stored; }; diff --git a/src/platform/plugins/shared/discover/public/plugin.tsx b/src/platform/plugins/shared/discover/public/plugin.tsx index e7a64b87d5347..63a9d0aa7a70d 100644 --- a/src/platform/plugins/shared/discover/public/plugin.tsx +++ b/src/platform/plugins/shared/discover/public/plugin.tsx @@ -29,7 +29,12 @@ 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 { DISCOVER_APP_LOCATOR, PLUGIN_ID, type DiscoverAppLocator } from '../common'; +import { + DISCOVER_APP_LOCATOR, + EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, + PLUGIN_ID, + type DiscoverAppLocator, +} from '../common'; import { DISCOVER_CONTEXT_APP_LOCATOR, type DiscoverContextAppLocator, @@ -470,11 +475,18 @@ export class DiscoverPlugin }); }); + let embeddableTransformsEnabled = false; + core.getStartServices().then(([{ featureFlags }]) => { + embeddableTransformsEnabled = featureFlags.getBooleanValue( + EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, + false + ); + }); plugins.embeddable.registerLegacyURLTransform( SEARCH_EMBEDDABLE_TYPE, async (transformDrilldownsOut: DrilldownTransforms['transformOut']) => { const { getTransformOut } = await getEmbeddableServices(); - return getTransformOut(transformDrilldownsOut); + return getTransformOut(transformDrilldownsOut, () => embeddableTransformsEnabled); } ); } diff --git a/src/platform/plugins/shared/discover/server/plugin.ts b/src/platform/plugins/shared/discover/server/plugin.ts index 67767aa515222..b43467bdb4ed9 100644 --- a/src/platform/plugins/shared/discover/server/plugin.ts +++ b/src/platform/plugins/shared/discover/server/plugin.ts @@ -25,6 +25,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'; @@ -64,9 +65,18 @@ export class DiscoverServerPlugin }); } + let embeddableTransformsEnabled = false; + core.getStartServices().then(([{ featureFlags }]) => { + featureFlags + .getBooleanValue$(EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, embeddableTransformsEnabled) + .subscribe((value) => { + embeddableTransformsEnabled = value; + }); + }); plugins.embeddable.registerEmbeddableFactory(createSearchEmbeddableFactory()); plugins.embeddable.registerTransforms(SEARCH_EMBEDDABLE_TYPE, { - getTransforms: getSearchEmbeddableTransforms, + getTransforms: (drilldownTransforms) => + getSearchEmbeddableTransforms(drilldownTransforms, () => embeddableTransformsEnabled), }); core.pricing.registerProductFeatures([ From ff5b701eb9aeaf07180ecf790043d892c9083557 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 19 Mar 2026 02:01:16 +0000 Subject: [PATCH 11/33] Changes from node scripts/jest_integration -u src/core/server/integration_tests/ci_checks --- .../saved_objects/check_registered_types.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 c5489e89c5301..becab250afad8 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": "c22babd2e893593cef3dceb905dfc5d754bf6edf5eca2deee5ba5f4cf6ba831e", + "search": "a9258da440fec81e87114bcdb0ef16e53a75a5c3620baeaaf657feb18e490440", "search-session": "7648bc4e0f7030ea596ab20690f2b6256ce071a206dea4912a00363737f10ba6", "search-telemetry": "c152fc7e66d5ac7907e81c0926be9c219a15181e10b418b2fbb86bab2760627c", "search_playground": "8facc7ad66d9ca130f71168704cb1d01bb3a9763125e6c42e3053de4502fd94e", @@ -1109,13 +1109,13 @@ describe('checking migration metadata changes on all registered SO types', () => "search|global: ce649a79d99c5ff5eb68d544635428ef87946d84", "search|mappings: 432d4dfdb5a33ce29d00ccdcfcda70d7c5f94b52", "search|schemas: 8d6477e08dfdf20335752a69994646f9da90741f", - "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.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", From 932995066520be442f3d8196b82e08736b06a1c7 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 19 Mar 2026 06:11:31 +0000 Subject: [PATCH 12/33] Changes from node scripts/lint_ts_projects --fix --- src/platform/plugins/shared/discover/tsconfig.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 9758853b79ec3..a1f5eacb0e5b2 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -130,6 +130,8 @@ "@kbn/scout", "@kbn/synthtrace-client", "@kbn/cps-utils", + "@kbn/core-saved-objects-common", + "@kbn/as-code-filters-constants", ], "exclude": ["target/**/*"] } From 06da548347192b61e94c8064ef32172bd1ea9ffe Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 19 Mar 2026 06:24:30 +0000 Subject: [PATCH 13/33] Changes from node scripts/regenerate_moon_projects.js --update --- src/platform/plugins/shared/discover/moon.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index e9b7513b1c5bf..b0660704dac29 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -131,9 +131,12 @@ dependsOn: - '@kbn/esql' - '@kbn/controls-schemas' - '@kbn/as-code-filters-schema' + - '@kbn/as-code-filters-transforms' - '@kbn/scout' - '@kbn/synthtrace-client' - '@kbn/cps-utils' + - '@kbn/core-saved-objects-common' + - '@kbn/as-code-filters-constants' tags: - plugin - prod From 08eba92948c6b0eddc80506f4b87a0593e57b8cf Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Thu, 19 Mar 2026 08:00:56 -0700 Subject: [PATCH 14/33] Fix type issues --- .../src/components/saved_search.tsx | 10 +++++--- .../get_search_embeddable_factory.test.tsx | 20 +++++++++------- .../plugins/shared/discover/public/index.ts | 1 + .../plugins/shared/discover/server/plugin.ts | 23 ++++++++++++------- 4 files changed, 35 insertions(+), 19 deletions(-) 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..8c31285b8aded 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 @@ -11,8 +11,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; 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 { getDefaultSort } from '@kbn/discover-utils'; +import { + SEARCH_EMBEDDABLE_TYPE, + 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 +272,7 @@ const SavedSearchComponentTable: React.FC< ); return ( - + maybeId={undefined} type={SEARCH_EMBEDDABLE_TYPE} getParentApi={() => parentApi} 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 61e26e90d824f..5fbe74252a20b 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 @@ -24,7 +24,11 @@ import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { createDataViewDataSource } from '../../common/data_sources'; 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'; @@ -147,7 +151,7 @@ describe('saved search embeddable', () => { runtimeState = getInitialRuntimeState({ searchMock: search }); const { Component, api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -182,7 +186,7 @@ describe('saved search embeddable', () => { const { Component, api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -264,7 +268,7 @@ describe('saved search embeddable', () => { }); const { api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -361,7 +365,7 @@ describe('saved search embeddable', () => { runtimeState = getInitialRuntimeState(); await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -386,7 +390,7 @@ describe('saved search embeddable', () => { }; await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -413,7 +417,7 @@ describe('saved search embeddable', () => { runtimeState = getInitialRuntimeState(); const { api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -446,7 +450,7 @@ describe('saved search embeddable', () => { }); const { Component, api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' }, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, 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/server/plugin.ts b/src/platform/plugins/shared/discover/server/plugin.ts index b43467bdb4ed9..dbe994d33574a 100644 --- a/src/platform/plugins/shared/discover/server/plugin.ts +++ b/src/platform/plugins/shared/discover/server/plugin.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; +import type { CoreSetup, CoreStart, Logger, Plugin } from '@kbn/core/server'; import type { PluginSetup as DataPluginSetup } from '@kbn/data-plugin/server'; import type { EmbeddableSetup } from '@kbn/embeddable-plugin/server'; import type { HomeServerPluginSetup } from '@kbn/home-plugin/server'; @@ -35,9 +35,11 @@ export class DiscoverServerPlugin implements Plugin { private readonly config: ConfigSchema; + private readonly logger: Logger; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); + this.logger = initializerContext.logger.get(); } public setup( @@ -66,13 +68,18 @@ export class DiscoverServerPlugin } let embeddableTransformsEnabled = false; - core.getStartServices().then(([{ featureFlags }]) => { - featureFlags - .getBooleanValue$(EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, embeddableTransformsEnabled) - .subscribe((value) => { - embeddableTransformsEnabled = value; - }); - }); + core + .getStartServices() + .then(([{ featureFlags }]) => { + featureFlags + .getBooleanValue$(EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, embeddableTransformsEnabled) + .subscribe((value) => { + embeddableTransformsEnabled = value; + }); + }) + .catch((error) => { + this.logger.error(error); + }); plugins.embeddable.registerEmbeddableFactory(createSearchEmbeddableFactory()); plugins.embeddable.registerTransforms(SEARCH_EMBEDDABLE_TYPE, { getTransforms: (drilldownTransforms) => From 008fb9c1890a1f17b3b88f83130e3cfb29257307 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Thu, 19 Mar 2026 11:59:19 -0700 Subject: [PATCH 15/33] Move import back to discover-utils --- .../kbn-saved-search-component/src/components/saved_search.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 8c31285b8aded..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 @@ -11,9 +11,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { debounceTime, distinctUntilChanged, map } from 'rxjs'; import { isEqual } from 'lodash'; import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; -import { getDefaultSort } from '@kbn/discover-utils'; +import { SEARCH_EMBEDDABLE_TYPE, getDefaultSort } from '@kbn/discover-utils'; import { - SEARCH_EMBEDDABLE_TYPE, type SearchEmbeddableApi, type SearchEmbeddablePanelApiState, } from '@kbn/discover-plugin/public'; From 1928dff988552910f6a995bdee8be15ef37f98d4 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Thu, 19 Mar 2026 15:31:00 -0700 Subject: [PATCH 16/33] refactor(discover): clarify embeddable transforms for legacy vs session API --- .../common/embeddable/get_transform_in.ts | 6 +- .../common/embeddable/get_transform_out.ts | 16 +- .../discover/common/embeddable/index.ts | 12 +- .../common/embeddable/transform_utils.test.ts | 175 +++++++++++------- .../common/embeddable/transform_utils.ts | 45 +++-- .../discover/common/embeddable/type_guards.ts | 25 ++- .../plugins/shared/discover/common/index.ts | 1 - .../embeddable/utils/serialization_utils.ts | 84 +++------ 8 files changed, 186 insertions(+), 178 deletions(-) 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 d79c9516694e6..50c299cb44e23 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 @@ -13,7 +13,7 @@ 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 { - isSearchEmbeddableByReferenceState, + isByValueSavedSearchEmbeddableState, isSearchEmbeddableLegacyPanelState, } from './type_guards'; import { discoverSessionToSavedSearchEmbeddableState } from './transform_utils'; @@ -23,8 +23,6 @@ import type { StoredSearchEmbeddableState, } from './types'; -export { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; - export function getTransformIn(transformDrilldownsIn: DrilldownTransforms['transformIn']) { return function transformIn(apiState: SearchEmbeddablePanelApiState): { state: StoredSearchEmbeddableState; @@ -41,7 +39,7 @@ function legacyTransformIn( storedState: SearchEmbeddableState, drilldownReferences: SavedObjectReference[] = [] ): { state: StoredSearchEmbeddableState; references: SavedObjectReference[] } { - if (isSearchEmbeddableByReferenceState(storedState)) { + if (!isByValueSavedSearchEmbeddableState(storedState)) { const { savedObjectId, ...rest } = storedState; return { state: rest, 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 96dc46ac28e20..973d80c53610e 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 @@ -15,12 +15,11 @@ import { transformTimeRangeOut, transformTitlesOut } from '@kbn/presentation-pub import { extractTabs, SavedSearchType } from '@kbn/saved-search-plugin/common'; import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; import type { - SearchEmbeddableByReferenceState, - SearchEmbeddableByValueState, SearchEmbeddablePanelApiState, + SearchEmbeddableState, StoredSearchEmbeddableState, } from './types'; -import { isSearchEmbeddableByValueState } from './type_guards'; +import { isByValueSavedSearchEmbeddableState } from './type_guards'; import { savedSearchToDiscoverSessionEmbeddableState } from './transform_utils'; export function getTransformOut( @@ -46,8 +45,8 @@ export function getTransformOut( function legacyTransformOut( state: StoredSearchEmbeddableState, references: SavedObjectReference[] | undefined -): SearchEmbeddableByReferenceState | SearchEmbeddableByValueState { - if (isSearchEmbeddableByValueState(state)) { +): SearchEmbeddableState { + if (isByValueSavedSearchEmbeddableState(state)) { const tabsState = { ...state, attributes: extractTabs(state.attributes) }; const tabs = tabsState.attributes.tabs.map((tab) => { try { @@ -76,14 +75,15 @@ function legacyTransformOut( ...state.attributes, tabs, }, - } as SearchEmbeddableByValueState; + }; } 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, - ...(savedObjectRef?.id ? { savedObjectId: savedObjectRef.id } : {}), - } as SearchEmbeddableByReferenceState; + 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 946c7071fdee3..c93606caa2715 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/index.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/index.ts @@ -11,9 +11,15 @@ export { getSearchEmbeddableTransforms, type SearchEmbeddablePanelApiState, } from './search_embeddable_transforms'; -export { isSearchEmbeddableLegacyPanelState } from './type_guards'; export { - discoverSessionToSavedSearchEmbeddableState, isByReferenceDiscoverSessionEmbeddableState, - isByReferenceSavedSearchEmbeddableState, + isSearchEmbeddableLegacyPanelState, +} from './type_guards'; +export { + byReferenceSavedSearchToDiscoverSessionEmbeddableState, + byValueDiscoverSessionToSavedSearchEmbeddableState, + byValueSavedSearchToDiscoverSessionEmbeddableState, + discoverSessionToSavedSearchEmbeddableState, + savedSearchToDiscoverSessionEmbeddableState, + toStoredSearchEmbeddableState, } from './transform_utils'; 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 index aa31dc950dc48..cf822babf576d 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -21,8 +21,6 @@ import { fromStoredSearchEmbeddableState, fromStoredSort, fromStoredTab, - isByReferenceDiscoverSessionEmbeddableState, - isByReferenceSavedSearchEmbeddableState, savedSearchToDiscoverSessionEmbeddableState, toStoredColumns, toStoredDataset, @@ -35,13 +33,13 @@ import { toStoredTab, } from './transform_utils'; import type { + SearchEmbeddableByReferenceState, StoredSearchEmbeddableByReferenceState, StoredSearchEmbeddableByValueState, StoredSearchEmbeddableState, } from './types'; import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; -import { SavedSearchType } from '@kbn/saved-search-plugin/common'; -import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; +import { SavedSearchType, VIEW_MODE } from '@kbn/saved-search-plugin/common'; import type { DiscoverSessionEmbeddableByReferenceState, DiscoverSessionEmbeddableByValueState, @@ -59,71 +57,6 @@ describe('search embeddable transform utils', () => { jest.clearAllMocks(); }); - describe('isByReferenceSavedSearchEmbeddableState', () => { - it('returns true when state has no attributes (by-reference)', () => { - const state: StoredSearchEmbeddableByReferenceState = { - title: 'My Search', - description: 'My description', - time_range: { from: 'now-15m', to: 'now' }, - }; - expect(isByReferenceSavedSearchEmbeddableState(state)).toBe(true); - }); - - it('returns false when state has attributes (by-value)', () => { - const state = { - title: 'My Search', - description: 'My description', - attributes: { - tabs: [], - title: '', - description: '', - sort: [], - columns: [], - grid: {}, - hideChart: false, - isTextBasedQuery: false, - kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, - }, - } as unknown as StoredSearchEmbeddableByValueState; - expect(isByReferenceSavedSearchEmbeddableState(state)).toBe(false); - }); - }); - - describe('isByReferenceDiscoverSessionEmbeddableState', () => { - it('returns true when state has discover_session_id', () => { - const state: DiscoverSessionEmbeddableByReferenceState = { - title: 'My Search', - description: 'My description', - time_range: { from: 'now-15m', to: 'now' }, - discover_session_id: 'session-123', - selected_tab_id: undefined, - overrides: {}, - }; - expect(isByReferenceDiscoverSessionEmbeddableState(state)).toBe(true); - }); - - it('returns false when state has tabs (by-value)', () => { - const state: DiscoverSessionEmbeddableByValueState = { - title: 'My Search', - description: 'My description', - tabs: [ - { - columns: [{ name: 'message' }], - sort: [], - view_mode: VIEW_MODE.DOCUMENT_LEVEL, - density: DataGridDensity.COMPACT, - header_row_height: 'auto', - row_height: 'auto', - query: { language: 'kuery', query: '' }, - filters: [], - dataset: { type: 'dataView', id: 'dv-1' }, - }, - ], - }; - expect(isByReferenceDiscoverSessionEmbeddableState(state)).toBe(false); - }); - }); - describe('savedSearchToDiscoverSessionEmbeddableState', () => { it('dispatches to by-reference transform when state has no attributes', () => { const storedState: StoredSearchEmbeddableByReferenceState = { @@ -348,6 +281,110 @@ describe('search embeddable transform utils', () => { 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 = byReferenceSavedSearchToDiscoverSessionEmbeddableState( + 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' }], + columns: [{ name: '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(() => + byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedSearch, []) + ).toThrow(`Missing reference of type "${SavedSearchType}"`); + expect(() => + byReferenceSavedSearchToDiscoverSessionEmbeddableState(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 = byReferenceSavedSearchToDiscoverSessionEmbeddableState( + 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 = byReferenceSavedSearchToDiscoverSessionEmbeddableState(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 = byReferenceSavedSearchToDiscoverSessionEmbeddableState( + storedSearch, + references + ); + expect(result.discover_session_id).toBe('id-from-state'); + }); }); describe('byReferenceDiscoverSessionToSavedSearchEmbeddableState', () => { diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts index 9053ff84f422e..6df14649137f7 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -17,9 +17,13 @@ import { parseSearchSourceJSON, } from '@kbn/data-plugin/common'; import { fromStoredFilters, toStoredFilters } from '@kbn/as-code-filters-transforms'; -import type { SavedObjectReference } from '@kbn/core-saved-objects-common/src/server_types'; +import type { SavedObjectReference } from '@kbn/core/server'; import { DataGridDensity } from '@kbn/discover-utils'; import { isOfAggregateQueryType } from '@kbn/es-query'; +import { + isByReferenceDiscoverSessionEmbeddableState, + isByValueSavedSearchEmbeddableState, +} from './type_guards'; import type { DiscoverSessionClassicTab, DiscoverSessionDataset, @@ -31,31 +35,21 @@ import type { DiscoverSessionTab, } from '../../server'; import type { + SearchEmbeddableByReferenceState, + SearchEmbeddableState, StoredSearchEmbeddableByReferenceState, StoredSearchEmbeddableByValueState, StoredSearchEmbeddableState, } from './types'; import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; -export function isByReferenceSavedSearchEmbeddableState( - state: StoredSearchEmbeddableState -): state is StoredSearchEmbeddableByReferenceState { - return !('attributes' in state); -} - -export function isByReferenceDiscoverSessionEmbeddableState( - state: DiscoverSessionEmbeddableState -): state is DiscoverSessionEmbeddableByReferenceState { - return 'discover_session_id' in state; -} - export function savedSearchToDiscoverSessionEmbeddableState( - storedState: StoredSearchEmbeddableState, + storedState: SearchEmbeddableState | StoredSearchEmbeddableState, references: SavedObjectReference[] = [] ): DiscoverSessionEmbeddableState { - return isByReferenceSavedSearchEmbeddableState(storedState) - ? byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedState, references) - : byValueSavedSearchToDiscoverSessionEmbeddableState(storedState, references); + return isByValueSavedSearchEmbeddableState(storedState) + ? byValueSavedSearchToDiscoverSessionEmbeddableState(storedState, references) + : byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedState, references); } export function discoverSessionToSavedSearchEmbeddableState( @@ -68,13 +62,9 @@ export function discoverSessionToSavedSearchEmbeddableState( } export function byReferenceSavedSearchToDiscoverSessionEmbeddableState( - storedState: StoredSearchEmbeddableByReferenceState, + storedState: SearchEmbeddableByReferenceState | StoredSearchEmbeddableByReferenceState, references: SavedObjectReference[] = [] ): DiscoverSessionEmbeddableByReferenceState { - 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}"`); const { sort, columns, @@ -84,12 +74,19 @@ export function byReferenceSavedSearchToDiscoverSessionEmbeddableState( headerRowHeight, density, grid, + savedObjectId, selectedTabId, ...otherAttrs - } = storedState; + } = { + 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: savedObjectRef.id, + discover_session_id: savedObjectId, selected_tab_id: selectedTabId, overrides: fromStoredSearchEmbeddableState(storedState), }; diff --git a/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts b/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts index 38568d1b82921..14cc17e51d027 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts @@ -8,26 +8,31 @@ */ import type { - SearchEmbeddableByReferenceState, + DiscoverSessionEmbeddableByReferenceState, + DiscoverSessionEmbeddableState, +} from '../../server'; +import type { SearchEmbeddableByValueState, + SearchEmbeddablePanelApiState, SearchEmbeddableState, + StoredSearchEmbeddableByValueState, StoredSearchEmbeddableState, } from './types'; -export function isSearchEmbeddableByReferenceState( - state: SearchEmbeddableState | StoredSearchEmbeddableState -): state is SearchEmbeddableByReferenceState { - return 'savedObjectId' in state; +export function isByReferenceDiscoverSessionEmbeddableState( + state: DiscoverSessionEmbeddableState +): state is DiscoverSessionEmbeddableByReferenceState { + return 'discover_session_id' in state; } -export function isSearchEmbeddableByValueState( - state: StoredSearchEmbeddableState -): state is SearchEmbeddableByValueState { +export function isByValueSavedSearchEmbeddableState( + state: SearchEmbeddableState | StoredSearchEmbeddableState +): state is SearchEmbeddableByValueState | StoredSearchEmbeddableByValueState { return 'attributes' in state && typeof state.attributes === 'object' && state.attributes !== null; } export function isSearchEmbeddableLegacyPanelState( - state: SearchEmbeddableState | StoredSearchEmbeddableState + state: SearchEmbeddablePanelApiState ): state is SearchEmbeddableState { - return isSearchEmbeddableByReferenceState(state) || isSearchEmbeddableByValueState(state); + return 'savedObjectId' in state || isByValueSavedSearchEmbeddableState(state); } diff --git a/src/platform/plugins/shared/discover/common/index.ts b/src/platform/plugins/shared/discover/common/index.ts index 07b6b38d745df..55c93a9294382 100644 --- a/src/platform/plugins/shared/discover/common/index.ts +++ b/src/platform/plugins/shared/discover/common/index.ts @@ -25,5 +25,4 @@ export type { NonPersistedDisplayOptions, SearchEmbeddableState } from './embedd export { discoverSessionToSavedSearchEmbeddableState, isByReferenceDiscoverSessionEmbeddableState, - isByReferenceSavedSearchEmbeddableState, } from './embeddable'; 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 16660a9b3b556..f030fc5b91916 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,39 +10,28 @@ import { omit, pick } from 'lodash'; import deepEqual from 'react-fast-compare'; import { type SerializedTimeRange, type SerializedTitles } from '@kbn/presentation-publishing'; -import { - type SavedSearch, - SavedSearchType, - toSavedSearchAttributes, -} 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 { isSearchEmbeddableLegacyPanelState } from '../../../common/embeddable'; +import type { + EditableSavedSearchAttributes, + SearchEmbeddableByReferenceState, + SearchEmbeddablePanelApiState, + StoredSearchEmbeddableByValueState, +} from '../../../common/embeddable/types'; import { byReferenceSavedSearchToDiscoverSessionEmbeddableState, byValueDiscoverSessionToSavedSearchEmbeddableState, byValueSavedSearchToDiscoverSessionEmbeddableState, + isSearchEmbeddableLegacyPanelState, + savedSearchToDiscoverSessionEmbeddableState, toStoredSearchEmbeddableState, -} from '../../../common/embeddable/transform_utils'; -import type { SearchEmbeddableState } from '../../../common'; +} from '../../../common/embeddable'; import { isByReferenceDiscoverSessionEmbeddableState } from '../../../common'; -import { - EDITABLE_SAVED_SEARCH_KEYS, - SAVED_SEARCH_SAVED_OBJECT_REF_NAME, -} from '../../../common/embeddable/constants'; -import type { - EditableSavedSearchAttributes, - SearchEmbeddablePanelApiState, - StoredSearchEmbeddableByReferenceState, - StoredSearchEmbeddableState, -} from '../../../common/embeddable/types'; +import { EDITABLE_SAVED_SEARCH_KEYS } from '../../../common/embeddable/constants'; import type { DiscoverServices } from '../../build_services'; import { EDITABLE_PANEL_KEYS } from '../constants'; import type { SearchEmbeddableInputState, SearchEmbeddableRuntimeState } from '../types'; import { isTabDeleted } from './is_tab_deleted'; -import { - isSearchEmbeddableByReferenceState, - isSearchEmbeddableByValueState, -} from '../../../common/embeddable/type_guards'; export const deserializeState = async ({ serializedState, @@ -52,33 +41,22 @@ export const deserializeState = async ({ discoverServices: DiscoverServices; }): Promise => { const panelState = pick(serializedState, EDITABLE_PANEL_KEYS); + const apiState = isSearchEmbeddableLegacyPanelState(serializedState) + ? savedSearchToDiscoverSessionEmbeddableState(serializedState) + : serializedState; - if ( - (isSearchEmbeddableLegacyPanelState(serializedState) && - isSearchEmbeddableByReferenceState(serializedState)) || - (!isSearchEmbeddableLegacyPanelState(serializedState) && - isByReferenceDiscoverSessionEmbeddableState(serializedState)) - ) { + if (isByReferenceDiscoverSessionEmbeddableState(apiState)) { // by reference - const savedObjectOverride = isSearchEmbeddableLegacyPanelState(serializedState) - ? pick(serializedState, EDITABLE_SAVED_SEARCH_KEYS) - : toStoredSearchEmbeddableState(serializedState.overrides ?? {}); - const savedObjectId = isSearchEmbeddableLegacyPanelState(serializedState) - ? serializedState.savedObjectId - : serializedState.discover_session_id; - const selectedTabId = isSearchEmbeddableLegacyPanelState(serializedState) - ? serializedState.selectedTabId - : serializedState.selected_tab_id; - + const { discover_session_id: savedObjectId, selected_tab_id: selectedTabId } = apiState; const { getDiscoverSession } = discoverServices.savedSearch; const session = await getDiscoverSession(savedObjectId); - 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 = toStoredSearchEmbeddableState(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 @@ -97,24 +75,14 @@ export const deserializeState = async ({ // Overwrite SO state with dashboard state for title, description, etc. ...panelState, }; - } else if (isSearchEmbeddableByValueState(serializedState)) { - const { byValueToSavedSearch } = discoverServices.savedSearch; - const savedSearch = await byValueToSavedSearch(serializedState, true); - - const { tabs, ...savedSearchWithoutTabs } = savedSearch; - - return { - ...savedSearchWithoutTabs, - ...panelState, - nonPersistedDisplayOptions: serializedState.nonPersistedDisplayOptions, - }; } else { - const [tab] = serializedState.tabs; + // by value + const [tab] = apiState.tabs; const savedObjectOverride = toStoredSearchEmbeddableState(tab ?? {}); const { byValueToSavedSearch } = discoverServices.savedSearch; const { state: storedState, references } = - byValueDiscoverSessionToSavedSearchEmbeddableState(serializedState); + byValueDiscoverSessionToSavedSearchEmbeddableState(apiState); const savedSearch = await byValueToSavedSearch( { attributes: { ...storedState.attributes, references } }, true @@ -180,22 +148,20 @@ export const serializeState = ({ }, {}); } - const storedByRef: StoredSearchEmbeddableByReferenceState = { + const stored: SearchEmbeddableByReferenceState = { ...serializeTitles(), ...serializeTimeRange(), ...serializeDynamicActions?.(), ...overwriteState, ...(selectedTabId !== undefined && { selectedTabId }), + savedObjectId, }; - const refs = [ - { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: savedObjectId }, - ]; return embeddableTransformsEnabled - ? byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedByRef, refs) - : ({ ...storedByRef, savedObjectId } as SearchEmbeddableState); + ? byReferenceSavedSearchToDiscoverSessionEmbeddableState(stored) + : stored; } - const stored: StoredSearchEmbeddableState = { + const stored: StoredSearchEmbeddableByValueState = { ...serializeTitles(), ...serializeTimeRange(), ...serializeDynamicActions?.(), From 76111a390f445bb21b4ddd14bf5f4913697f4267 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Thu, 19 Mar 2026 15:48:15 -0700 Subject: [PATCH 17/33] Optimize bundle size --- src/platform/plugins/shared/discover/common/index.ts | 5 ----- .../discover/public/embeddable/utils/serialization_utils.ts | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/platform/plugins/shared/discover/common/index.ts b/src/platform/plugins/shared/discover/common/index.ts index 55c93a9294382..43fe31a8499f9 100644 --- a/src/platform/plugins/shared/discover/common/index.ts +++ b/src/platform/plugins/shared/discover/common/index.ts @@ -21,8 +21,3 @@ export type { export type { DiscoverESQLLocator, DiscoverESQLLocatorParams } from './esql_locator'; export type { NonPersistedDisplayOptions, SearchEmbeddableState } from './embeddable/types'; - -export { - discoverSessionToSavedSearchEmbeddableState, - isByReferenceDiscoverSessionEmbeddableState, -} from './embeddable'; 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 f030fc5b91916..a134c5e611b80 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 @@ -22,11 +22,11 @@ import { byReferenceSavedSearchToDiscoverSessionEmbeddableState, byValueDiscoverSessionToSavedSearchEmbeddableState, byValueSavedSearchToDiscoverSessionEmbeddableState, + isByReferenceDiscoverSessionEmbeddableState, isSearchEmbeddableLegacyPanelState, savedSearchToDiscoverSessionEmbeddableState, toStoredSearchEmbeddableState, } from '../../../common/embeddable'; -import { isByReferenceDiscoverSessionEmbeddableState } from '../../../common'; import { EDITABLE_SAVED_SEARCH_KEYS } from '../../../common/embeddable/constants'; import type { DiscoverServices } from '../../build_services'; import { EDITABLE_PANEL_KEYS } from '../constants'; From 83a7ce44992400f73b9ea1117653264e886b3f1c Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Fri, 20 Mar 2026 06:49:51 -0700 Subject: [PATCH 18/33] Actually register schema --- src/platform/plugins/shared/discover/server/plugin.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/platform/plugins/shared/discover/server/plugin.ts b/src/platform/plugins/shared/discover/server/plugin.ts index dbe994d33574a..2d90a7b52d2af 100644 --- a/src/platform/plugins/shared/discover/server/plugin.ts +++ b/src/platform/plugins/shared/discover/server/plugin.ts @@ -15,6 +15,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 { discoverSessionEmbeddableSchema } from './embeddable/schema'; import type { DiscoverServerPluginStart, DiscoverServerPluginStartDeps } from '.'; import { DISCOVER_APP_LOCATOR } from '../common'; import { capabilitiesProvider } from './capabilities_provider'; @@ -84,6 +85,7 @@ export class DiscoverServerPlugin plugins.embeddable.registerTransforms(SEARCH_EMBEDDABLE_TYPE, { getTransforms: (drilldownTransforms) => getSearchEmbeddableTransforms(drilldownTransforms, () => embeddableTransformsEnabled), + getSchema: () => discoverSessionEmbeddableSchema, }); core.pricing.registerProductFeatures([ From d7218be9be9cbe0449adb2d330bc80664e466451 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Tue, 24 Mar 2026 14:36:59 -0700 Subject: [PATCH 19/33] Fix issue with save and return for by val --- .../common/embeddable/transform_utils.test.ts | 53 +++++++++++++++++++ .../common/embeddable/transform_utils.ts | 5 +- 2 files changed, 57 insertions(+), 1 deletion(-) 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 index cf822babf576d..f3ad05cc55177 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -124,6 +124,59 @@ describe('search embeddable transform utils', () => { 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 = savedSearchToDiscoverSessionEmbeddableState(storedState); + + expect('tabs' in result && result.tabs).toBeDefined(); + expect('tabs' in result && result.tabs?.[0]).toMatchObject({ + dataset: { type: 'dataView', id: dataViewId }, + }); + }); }); describe('discoverSessionToSavedSearchEmbeddableState', () => { diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts index 6df14649137f7..cd34eac746dff 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -48,7 +48,10 @@ export function savedSearchToDiscoverSessionEmbeddableState( references: SavedObjectReference[] = [] ): DiscoverSessionEmbeddableState { return isByValueSavedSearchEmbeddableState(storedState) - ? byValueSavedSearchToDiscoverSessionEmbeddableState(storedState, references) + ? byValueSavedSearchToDiscoverSessionEmbeddableState(storedState, [ + ...references, + ...(storedState.attributes.references ?? []), + ]) : byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedState, references); } From 436519bb895cce6939b158976ef380d0041f92b6 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Tue, 24 Mar 2026 16:28:23 -0700 Subject: [PATCH 20/33] Add proper support for drilldown config --- .../discover/server/embeddable/schema.ts | 57 ++++++++++++------- .../plugins/shared/discover/server/plugin.ts | 4 +- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/src/platform/plugins/shared/discover/server/embeddable/schema.ts b/src/platform/plugins/shared/discover/server/embeddable/schema.ts index ef611e6baca2c..23498ca40f7b0 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -7,7 +7,7 @@ * 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 } from '@kbn/es-query-server'; @@ -17,7 +17,8 @@ import { } from '@kbn/presentation-publishing-schemas'; import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; import { asCodeFilterSchema } from '@kbn/as-code-filters-schema'; -import type { SerializedDrilldowns } from '@kbn/embeddable-plugin/server'; +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({ @@ -426,9 +427,23 @@ const esqlTabSchema = schema.allOf([ const tabSchema = schema.oneOf([classicTabSchema, esqlTabSchema]); -const discoverSessionByValueEmbeddableSchema = schema.allOf([ - serializedTitlesSchema, - serializedTimeRangeSchema, +const DISCOVER_SUPPORTED_DRILLDOWN_TRIGGERS = [ON_OPEN_PANEL_MENU]; + +/** + * 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

) { + return (getDrilldownsSchema: GetDrilldownsSchemaFnType) => + schema.allOf([ + serializedTitlesSchema, + serializedTimeRangeSchema, + getDrilldownsSchema(DISCOVER_SUPPORTED_DRILLDOWN_TRIGGERS), + embeddableSchema, + ]); +} + +const getDiscoverSessionByValueEmbeddableSchema = withPanelSchemas( schema.object({ tabs: schema.arrayOf(tabSchema, { minSize: 1, @@ -438,12 +453,10 @@ const discoverSessionByValueEmbeddableSchema = schema.allOf([ 'Inline tab configuration. Used when no `discover_session_id` is set. Panel-level fields (e.g. `columns`, `sort`) override these when provided. Currently supports one tab.', }, }), - }), -]); + }) +); -const discoverSessionByReferenceEmbeddableSchema = schema.allOf([ - serializedTitlesSchema, - serializedTimeRangeSchema, +const getDiscoverSessionByReferenceEmbeddableSchema = withPanelSchemas( schema.object({ discover_session_id: schema.string(), selected_tab_id: schema.maybe( @@ -455,13 +468,16 @@ const discoverSessionByReferenceEmbeddableSchema = schema.allOf([ }) ), overrides: panelOverridesSchema, - }), -]); + }) +); -export const discoverSessionEmbeddableSchema = schema.oneOf([ - discoverSessionByValueEmbeddableSchema, - discoverSessionByReferenceEmbeddableSchema, -]); +export const getDiscoverSessionEmbeddableSchema = ( + getDrilldownsSchema: GetDrilldownsSchemaFnType +) => + schema.oneOf([ + getDiscoverSessionByValueEmbeddableSchema(getDrilldownsSchema), + getDiscoverSessionByReferenceEmbeddableSchema(getDrilldownsSchema), + ]); export type DiscoverSessionDataViewReference = TypeOf; export type DiscoverSessionDataViewSpec = TypeOf; @@ -472,10 +488,11 @@ 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 = SerializedDrilldowns & - TypeOf; diff --git a/src/platform/plugins/shared/discover/server/plugin.ts b/src/platform/plugins/shared/discover/server/plugin.ts index 2d90a7b52d2af..8a2ce83a5baad 100644 --- a/src/platform/plugins/shared/discover/server/plugin.ts +++ b/src/platform/plugins/shared/discover/server/plugin.ts @@ -15,7 +15,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 { discoverSessionEmbeddableSchema } from './embeddable/schema'; +import { getDiscoverSessionEmbeddableSchema } from './embeddable/schema'; import type { DiscoverServerPluginStart, DiscoverServerPluginStartDeps } from '.'; import { DISCOVER_APP_LOCATOR } from '../common'; import { capabilitiesProvider } from './capabilities_provider'; @@ -85,7 +85,7 @@ export class DiscoverServerPlugin plugins.embeddable.registerTransforms(SEARCH_EMBEDDABLE_TYPE, { getTransforms: (drilldownTransforms) => getSearchEmbeddableTransforms(drilldownTransforms, () => embeddableTransformsEnabled), - getSchema: () => discoverSessionEmbeddableSchema, + getSchema: (getDrilldownsSchema) => getDiscoverSessionEmbeddableSchema(getDrilldownsSchema), }); core.pricing.registerProductFeatures([ From 3369e73113a1da703012108b05ddc3d20561774e Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Tue, 24 Mar 2026 16:51:52 -0700 Subject: [PATCH 21/33] Don't register schema if feature flag is turned off --- src/platform/plugins/shared/discover/server/plugin.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/platform/plugins/shared/discover/server/plugin.ts b/src/platform/plugins/shared/discover/server/plugin.ts index 8a2ce83a5baad..e42fc84235f1c 100644 --- a/src/platform/plugins/shared/discover/server/plugin.ts +++ b/src/platform/plugins/shared/discover/server/plugin.ts @@ -85,7 +85,10 @@ export class DiscoverServerPlugin plugins.embeddable.registerTransforms(SEARCH_EMBEDDABLE_TYPE, { getTransforms: (drilldownTransforms) => getSearchEmbeddableTransforms(drilldownTransforms, () => embeddableTransformsEnabled), - getSchema: (getDrilldownsSchema) => getDiscoverSessionEmbeddableSchema(getDrilldownsSchema), + getSchema: (getDrilldownsSchema) => + embeddableTransformsEnabled + ? getDiscoverSessionEmbeddableSchema(getDrilldownsSchema) + : undefined, }); core.pricing.registerProductFeatures([ From da4e44d17c01cd01d0f98f4a069ea2e026883efc Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Wed, 25 Mar 2026 14:15:34 -0700 Subject: [PATCH 22/33] Separate into and for BWC --- .../search_embeddable_transforms.test.ts | 15 +- .../common/embeddable/transform_utils.test.ts | 133 +++++++----------- .../common/embeddable/transform_utils.ts | 43 +++--- .../utils/serialization_utils.test.ts | 8 +- .../discover/server/embeddable/schema.ts | 59 +++++--- 5 files changed, 121 insertions(+), 137 deletions(-) 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 1198ceffe3ec1..e2b0db02144aa 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 @@ -121,10 +121,10 @@ describe('searchEmbeddableTransforms', () => { expect(result.title).toBe('Panel Title'); expect(result.description).toBe('Panel description'); expect(result.tabs).toHaveLength(1); - expect(result.tabs[0].columns).toEqual([ - { name: 'message' }, - { name: '@timestamp', width: 200 }, - ]); + expect(result.tabs[0].column_order).toEqual(['message', '@timestamp']); + expect(result.tabs[0].column_settings).toEqual({ + '@timestamp': { width: 200 }, + }); const { sort, view_mode: viewMode, @@ -182,7 +182,6 @@ describe('searchEmbeddableTransforms', () => { title: 'Test Search', description: 'Test Description', time_range: { from: 'now-15m', to: 'now' }, - grid: {}, }); expect(result.references).toEqual([ { @@ -211,7 +210,6 @@ describe('searchEmbeddableTransforms', () => { title: 'My Search', description: 'My description', time_range: { from: 'now-1h', to: 'now' }, - grid: {}, selectedTabId: 'tab-1', }); expect(result.references).toEqual([ @@ -231,7 +229,8 @@ describe('searchEmbeddableTransforms', () => { description: 'Panel description', tabs: [ { - columns: [{ name: 'message' }, { name: '@timestamp', width: 200 }], + column_order: ['message', '@timestamp'], + column_settings: { '@timestamp': { width: 200 } }, sort: [{ name: '@timestamp', direction: 'desc' }], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, @@ -271,7 +270,7 @@ describe('searchEmbeddableTransforms', () => { title: 'Panel Title', tabs: [ { - columns: [{ name: '_source' }], + column_order: ['_source'], sort: [], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, 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 index f3ad05cc55177..36ec2edf4d7c7 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -14,15 +14,14 @@ import { byValueDiscoverSessionToSavedSearchEmbeddableState, byValueSavedSearchToDiscoverSessionEmbeddableState, discoverSessionToSavedSearchEmbeddableState, - fromStoredColumns, fromStoredDataset, + fromStoredGrid, fromStoredHeight, fromStoredRuntimeFields, fromStoredSearchEmbeddableState, fromStoredSort, fromStoredTab, savedSearchToDiscoverSessionEmbeddableState, - toStoredColumns, toStoredDataset, toStoredFieldFormats, toStoredGrid, @@ -208,7 +207,7 @@ describe('search embeddable transform utils', () => { description: 'Panel description', tabs: [ { - columns: [{ name: 'message' }], + column_order: ['message'], sort: [], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, @@ -293,7 +292,7 @@ describe('search embeddable transform utils', () => { }, ], sort: [{ name: '@timestamp', direction: 'desc' }], - columns: [{ name: 'message' }], + column_order: ['message'], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, header_row_height: 3, @@ -369,7 +368,8 @@ describe('search embeddable transform utils', () => { selected_tab_id: 'tab-active', overrides: { sort: [{ name: '@timestamp', direction: 'desc' }], - columns: [{ name: 'message', width: 100 }], + column_order: ['message'], + column_settings: { message: { width: 100 } }, row_height: 'auto', sample_size: 500, rows_per_page: 100, @@ -462,7 +462,6 @@ describe('search embeddable transform utils', () => { title: 'My Search', description: 'My description', time_range: { from: 'now-15m', to: 'now' }, - grid: {}, selectedTabId: 'tab-1', }); }); @@ -476,7 +475,8 @@ describe('search embeddable transform utils', () => { time_range: { from: 'now-1h', to: 'now' }, tabs: [ { - columns: [{ name: 'message' }, { name: '@timestamp', width: 200 }], + column_order: ['message', '@timestamp'], + column_settings: { '@timestamp': { width: 200 } }, sort: [{ name: '@timestamp', direction: 'desc' }], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, @@ -503,6 +503,9 @@ describe('search embeddable transform utils', () => { expect(result.state.attributes.tabs).toHaveLength(1); 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( @@ -520,7 +523,7 @@ describe('search embeddable transform utils', () => { time_range: { from: 'now-1h', to: 'now' }, tabs: [ { - columns: [{ name: 'foo' }], + column_order: ['foo'], sort: [], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, @@ -672,84 +675,49 @@ describe('search embeddable transform utils', () => { }); }); - describe('fromStoredColumns', () => { - it('maps column names to column objects without width when grid has no column widths', () => { - const columns = ['message', '@timestamp']; - const grid = { columns: {} }; - const result = fromStoredColumns(columns, grid); - expect(result).toEqual([{ name: 'message' }, { name: '@timestamp' }]); - }); - - it('includes width from grid when present', () => { - const columns = ['message', '@timestamp']; - const grid = { - columns: { - message: { width: 100 }, - '@timestamp': { width: 200 }, - }, - }; - const result = fromStoredColumns(columns, grid); - expect(result).toEqual([ - { name: 'message', width: 100 }, - { name: '@timestamp', width: 200 }, - ]); - }); - - it('includes width only for columns that have it in grid', () => { - const columns = ['message', '@timestamp', 'source']; - const grid = { - columns: { - '@timestamp': { width: 150 }, - }, - }; - const result = fromStoredColumns(columns, grid); - expect(result).toEqual([ - { name: 'message' }, - { name: '@timestamp', width: 150 }, - { name: 'source' }, - ]); - }); - }); - - describe('toStoredColumns', () => { - it('maps column objects to column names', () => { - const columns = [{ name: 'message' }, { name: '@timestamp', width: 200 }]; - const result = toStoredColumns(columns); - expect(result).toEqual(['message', '@timestamp']); - }); - - it('returns empty array when columns is empty', () => { - expect(toStoredColumns([])).toEqual([]); + 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 array when columns is undefined (default)', () => { - expect(toStoredColumns()).toEqual([]); + it('returns empty object when grid has no column entries', () => { + expect(fromStoredGrid({ columns: {} })).toEqual({}); + expect(fromStoredGrid({})).toEqual({}); }); }); describe('toStoredGrid', () => { - it('builds grid from columns (only columns with width are included)', () => { - const columns = [{ name: 'message' }, { name: '@timestamp', width: 200 }]; - const result = toStoredGrid(columns); - expect(result).toEqual({ + 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 columns is empty (no columns with width)', () => { - expect(toStoredGrid([])).toEqual({}); + it('returns empty object when column_settings is empty', () => { + expect(toStoredGrid({})).toEqual({}); }); - it('returns empty object when columns is undefined (default)', () => { + it('returns empty object when column_settings is undefined (default)', () => { expect(toStoredGrid()).toEqual({}); }); - - it('returns empty object when no column has width', () => { - const columns = [{ name: 'message' }, { name: '@timestamp' }]; - expect(toStoredGrid(columns)).toEqual({}); - }); }); describe('fromStoredSearchEmbeddableState', () => { @@ -772,10 +740,11 @@ describe('search embeddable transform utils', () => { const result = fromStoredSearchEmbeddableState(storedState); expect(result).toEqual({ sort: [{ name: '@timestamp', direction: 'desc' }], - columns: [ - { name: 'message', width: 100 }, - { name: '@timestamp', width: 200 }, - ], + column_order: ['message', '@timestamp'], + column_settings: { + message: { width: 100 }, + '@timestamp': { width: 200 }, + }, row_height: 'auto', sample_size: 500, rows_per_page: 100, @@ -793,7 +762,7 @@ describe('search embeddable transform utils', () => { const result = fromStoredSearchEmbeddableState(storedState); expect(result).toEqual({ sort: [{ name: '@timestamp', direction: 'desc' }], - columns: [{ name: 'message' }], + column_order: ['message'], }); expect(result.row_height).toBeUndefined(); expect(result.sample_size).toBeUndefined(); @@ -827,7 +796,8 @@ describe('search embeddable transform utils', () => { it('converts panel overrides with all fields to stored state', () => { const apiState = { sort: [{ name: '@timestamp', direction: 'desc' as const }], - columns: [{ name: 'message' }, { name: '@timestamp', width: 200 }], + column_order: ['message', '@timestamp'], + column_settings: { '@timestamp': { width: 200 } }, row_height: 'auto' as const, sample_size: 500, rows_per_page: 100 as const, @@ -854,13 +824,12 @@ describe('search embeddable transform utils', () => { it('omits undefined/falsy API fields from result', () => { const apiState = { sort: [{ name: '@timestamp', direction: 'desc' as const }], - columns: [{ name: 'message' }], + column_order: ['message'], }; const result = toStoredSearchEmbeddableState(apiState); expect(result).toEqual({ sort: [['@timestamp', 'desc']], columns: ['message'], - grid: {}, }); expect(result.rowHeight).toBeUndefined(); expect(result.sampleSize).toBeUndefined(); @@ -1316,7 +1285,8 @@ describe('search embeddable transform utils', () => { references ); expect(result.sort).toEqual([{ name: '@timestamp', direction: 'desc' }]); - expect(result.columns).toEqual([{ name: 'message' }, { name: '@timestamp', width: 200 }]); + 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); @@ -1331,7 +1301,8 @@ describe('search embeddable transform utils', () => { describe('toStoredTab', () => { it('converts API classic tab to stored tab with references', () => { const apiTab: DiscoverSessionEmbeddableByValueState['tabs'][0] = { - columns: [{ name: 'message' }, { name: '@timestamp', width: 200 }], + column_order: ['message', '@timestamp'], + column_settings: { '@timestamp': { width: 200 } }, sort: [{ name: '@timestamp', direction: 'desc' }], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, @@ -1365,7 +1336,7 @@ describe('search embeddable transform utils', () => { it('converts API tab with index-pattern dataset (no refs) when inline', () => { const apiTab: DiscoverSessionEmbeddableByValueState['tabs'][0] = { - columns: [{ name: 'foo' }], + column_order: ['foo'], sort: [], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts index cd34eac746dff..70a59d6cddd5e 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -213,7 +213,7 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { state: DiscoverSessionTabAttributes; references: SavedObjectReference[]; } { - const { sort, columns } = apiTab; + const { sort, column_order: columnOrder, column_settings: columnSettings } = apiTab; const searchSourceValues: SerializedSearchSourceFields = { query: apiTab.query, ...('filters' in apiTab && { filter: toStoredFilters(apiTab.filters) }), @@ -223,8 +223,8 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { const state: DiscoverSessionTabAttributes = { ...toStoredSearchEmbeddableState(apiTab), sort: toStoredSort(sort), - columns: toStoredColumns(columns), - grid: toStoredGrid(columns), + columns: columnOrder ?? [], + grid: toStoredGrid(columnSettings), hideChart: false, isTextBasedQuery: !('dataset' in apiTab), kibanaSavedObjectMeta: { searchSourceJSON: JSON.stringify(searchSourceFields) }, @@ -240,7 +240,9 @@ export function fromStoredSearchEmbeddableState( storedState; return { ...(sort && { sort: fromStoredSort(sort) }), - ...(columns && { columns: fromStoredColumns(columns, grid) }), + ...(columns && { column_order: columns }), + ...(grid && + Object.keys(grid?.columns ?? {}).length && { column_settings: fromStoredGrid(grid) }), ...(rowHeight && { row_height: fromStoredHeight(rowHeight) }), ...(sampleSize && { sample_size: sampleSize }), ...(rowsPerPage && { @@ -256,49 +258,36 @@ export function toStoredSearchEmbeddableState( ): StoredSearchEmbeddableState { const { sort, - columns, + column_order: columnOrder, + column_settings: columnSettings, row_height: rowHeight, sample_size: sampleSize, rows_per_page: rowsPerPage, header_row_height: headerRowHeight, density, } = apiState; - const grid = toStoredGrid(columns); return { ...(sort && { sort: toStoredSort(sort) }), - ...(columns && { columns: toStoredColumns(columns) }), + ...(columnOrder && { columns: columnOrder }), ...(rowHeight && { rowHeight: toStoredHeight(rowHeight) }), ...(sampleSize && { sampleSize }), ...(rowsPerPage && { rowsPerPage }), ...(headerRowHeight && { headerRowHeight: toStoredHeight(headerRowHeight) }), ...(density && { density }), - ...(grid && { grid }), + ...(Object.keys(columnSettings ?? {}).length && { grid: toStoredGrid(columnSettings) }), }; } -export function fromStoredColumns( - columns: DiscoverSessionTabAttributes['columns'], - grid?: DiscoverSessionTabAttributes['grid'] -): DiscoverSessionTab['columns'] { - return columns.map((name) => ({ - name, - ...(grid?.columns?.[name] && { width: grid.columns[name]?.width }), - })); -} - -export function toStoredColumns( - columns: DiscoverSessionTab['columns'] = [] -): DiscoverSessionTabAttributes['columns'] { - return columns.map((c) => c.name); +export function fromStoredGrid( + grid: DiscoverSessionTabAttributes['grid'] +): DiscoverSessionTab['column_settings'] { + return grid.columns ?? {}; } export function toStoredGrid( - columns: DiscoverSessionTab['columns'] = [] + columnSettings: DiscoverSessionTab['column_settings'] = {} ): DiscoverSessionTabAttributes['grid'] { - const entries = columns - ?.filter((c) => c.width != null) // Only persist columns with a width defined - .map(({ name, width }) => [name, { width }]); - return entries.length ? { columns: Object.fromEntries(entries) } : {}; + return Object.keys(columnSettings ?? {}).length > 0 ? { columns: columnSettings } : {}; } export function fromStoredSort( 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 c98274a599dfa..7b1be922174a3 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 @@ -79,7 +79,7 @@ describe('Serialization utils', () => { description: 'description', tabs: [ { - columns: [{ name: '_source' }], + column_order: ['_source'], sort: [{ name: 'order_date', direction: 'desc' }], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, @@ -257,7 +257,7 @@ describe('Serialization utils', () => { discover_session_id: 'savedSearch', selected_tab_id: 'deleted-tab-id', overrides: { - columns: [{ name: 'stale-col-a' }], + column_order: ['stale-col-a'], sort: [{ name: 'stale_field', direction: 'asc' }], }, }; @@ -288,7 +288,7 @@ describe('Serialization utils', () => { title: 'test panel title', discover_session_id: 'savedSearch', selected_tab_id: 'tab-2', - overrides: { columns: [{ name: 'custom-col' }] }, + overrides: { column_order: ['custom-col'] }, }; const deserializedState = await deserializeState({ @@ -339,7 +339,7 @@ describe('Serialization utils', () => { description: 'description', tabs: [ expect.objectContaining({ - columns: [{ name: '_source' }], + column_order: ['_source'], sort: [{ name: 'order_date', direction: 'desc' }], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, diff --git a/src/platform/plugins/shared/discover/server/embeddable/schema.ts b/src/platform/plugins/shared/discover/server/embeddable/schema.ts index 23498ca40f7b0..d67ae5c387030 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -20,12 +20,7 @@ import { asCodeFilterSchema } from '@kbn/as-code-filters-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, @@ -220,13 +215,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.', }, }) ), @@ -290,13 +300,28 @@ const dataTableSchema = schema.object( const panelOverridesSchema = 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: + '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: - 'Columns to display in the data table. When set, overrides the referenced saved object (when `discover_session_id` is used) or the inline tab config in `tabs`. If omitted, falls back to the source or to the advanced setting "defaultColumns".', + '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.', }, }) ), @@ -450,7 +475,7 @@ const getDiscoverSessionByValueEmbeddableSchema = withPanelSchemas( maxSize: 1, meta: { description: - 'Inline tab configuration. Used when no `discover_session_id` is set. Panel-level fields (e.g. `columns`, `sort`) override these when provided. Currently supports one tab.', + 'Inline tab configuration. Used when no `discover_session_id` is set. Panel-level fields (e.g. `column_order`, `sort`) override these when provided. Currently supports one tab.', }, }), }) From c0bb2a5807a443294cd3608211f18ec8345b6465 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:36:02 +0000 Subject: [PATCH 23/33] Changes from node scripts/lint_ts_projects --fix --- src/platform/plugins/shared/discover/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 3424640bcfa7e..2b9747405350d 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -132,7 +132,6 @@ "@kbn/cps-utils", "@kbn/core-saved-objects-common", "@kbn/as-code-filters-constants", - "@kbn/as-code-data-views-schema", ], "exclude": ["target/**/*"] } From 28c80e5a4bd39b17fa95b2ad11296a5beb12d473 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:49:45 +0000 Subject: [PATCH 24/33] Changes from node scripts/regenerate_moon_projects.js --update --- src/platform/plugins/shared/discover/moon.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index efd25a37c6c5b..1f71a7f59a19a 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -137,7 +137,6 @@ dependsOn: - '@kbn/cps-utils' - '@kbn/core-saved-objects-common' - '@kbn/as-code-filters-constants' - - '@kbn/as-code-data-views-schema' tags: - plugin - prod From 56ff6436f8cbc1d6bc99ae53b018790ad7f2c5ce Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Thu, 26 Mar 2026 11:23:42 -0700 Subject: [PATCH 25/33] Use runtime field schema/transforms --- .../common/embeddable/transform_utils.test.ts | 167 ++---------------- .../common/embeddable/transform_utils.ts | 56 ++---- src/platform/plugins/shared/discover/moon.yml | 2 + .../discover/server/embeddable/schema.ts | 63 +------ .../plugins/shared/discover/tsconfig.json | 2 + 5 files changed, 32 insertions(+), 258 deletions(-) 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 index 36ec2edf4d7c7..b04c6beedd9f9 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -17,16 +17,13 @@ import { fromStoredDataset, fromStoredGrid, fromStoredHeight, - fromStoredRuntimeFields, fromStoredSearchEmbeddableState, fromStoredSort, fromStoredTab, savedSearchToDiscoverSessionEmbeddableState, toStoredDataset, - toStoredFieldFormats, toStoredGrid, toStoredHeight, - toStoredRuntimeFields, toStoredSearchEmbeddableState, toStoredSort, toStoredTab, @@ -542,7 +539,7 @@ describe('search embeddable transform utils', () => { name: 'rt', type: 'keyword', script: 'emit("x")', - format: { id: 'string' }, + format: { type: 'string' }, }, ], }, @@ -559,6 +556,9 @@ describe('search embeddable transform utils', () => { fieldFormats: { rt: { id: 'string' }, }, + fieldAttrs: { + rt: {}, + }, runtimeFieldMap: { rt: { type: 'keyword', @@ -1027,7 +1027,7 @@ describe('search embeddable transform utils', () => { name: 'foobar', script: 'emit(UUID.randomUUID().toString())', format: { - id: 'url', + type: 'url', params: { parsedUrl: { origin: 'http://localhost:5601', @@ -1041,6 +1041,8 @@ describe('search embeddable transform utils', () => { height: null, }, }, + custom_label: 'my custom label', + custom_description: 'my custom description', }, ], }; @@ -1049,60 +1051,6 @@ describe('search embeddable transform utils', () => { }); }); - describe('fromStoredRuntimeFields', () => { - it('transforms runtime fields with field formats', () => { - const runtimeFieldMap: DataViewSpec['runtimeFieldMap'] = { - foobar: { - type: 'keyword', - script: { - source: 'emit(UUID.randomUUID().toString())', - }, - }, - }; - const fieldFormats: DataViewSpec['fieldFormats'] = { - foobar: { - id: 'url', - params: { - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/app/dashboards', - basePath: '', - }, - type: 'a', - urlTemplate: 'http://google.com?q={{value}}', - labelTemplate: 'google search for {{value}}', - width: null, - height: null, - }, - }, - }; - const expected: DiscoverSessionDataViewSpec['runtime_fields'] = [ - { - type: 'keyword', - name: 'foobar', - script: 'emit(UUID.randomUUID().toString())', - format: { - id: 'url', - params: { - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/app/dashboards', - basePath: '', - }, - type: 'a', - urlTemplate: 'http://google.com?q={{value}}', - labelTemplate: 'google search for {{value}}', - width: null, - height: null, - }, - }, - }, - ]; - const result = fromStoredRuntimeFields(runtimeFieldMap, fieldFormats); - expect(result).toEqual(expected); - }); - }); - describe('toStoredDataset', () => { it('converts dataView dataset to string id', () => { const dataset: DiscoverSessionDataViewReference = { @@ -1123,7 +1071,7 @@ describe('search embeddable transform utils', () => { name: 'rt', type: 'keyword', script: 'emit(doc["id"].value)', - format: { id: 'string' }, + format: { type: 'string' }, }, ], }; @@ -1132,7 +1080,10 @@ describe('search embeddable transform utils', () => { title: 'my-index-*', timeFieldName: '@timestamp', fieldFormats: { - rt: { id: 'string' }, + rt: { id: 'string', params: undefined }, + }, + fieldAttrs: { + rt: {}, }, runtimeFieldMap: { rt: { @@ -1157,100 +1108,6 @@ describe('search embeddable transform utils', () => { }); }); - describe('toStoredRuntimeFields', () => { - it('converts runtime fields to DataViewSpec runtimeFieldMap', () => { - const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ - { - name: 'myField', - type: 'keyword', - script: 'emit("hello")', - format: { id: 'url', params: {} }, - }, - ]; - const result = toStoredRuntimeFields(runtimeFields); - expect(result).toEqual({ - myField: { - type: 'keyword', - script: { source: 'emit("hello")' }, - }, - }); - }); - - it('omits script when not present', () => { - const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ - { name: 'f', type: 'long' }, - ]; - const result = toStoredRuntimeFields(runtimeFields); - expect(result).toEqual({ - f: { type: 'long' }, - }); - }); - - it('omits fieldFormat when not present', () => { - const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ - { name: 'f', type: 'keyword', script: 'emit("x")' }, - ]; - const result = toStoredRuntimeFields(runtimeFields); - expect(result).toEqual({ - f: { type: 'keyword', script: { source: 'emit("x")' } }, - }); - }); - - it('returns empty object when runtimeFields is undefined (default)', () => { - expect(toStoredRuntimeFields()).toEqual({}); - }); - - it('returns empty object when runtimeFields is empty array', () => { - expect(toStoredRuntimeFields([])).toEqual({}); - }); - }); - - describe('toStoredFieldFormats', () => { - it('converts runtime fields with format to fieldFormats object', () => { - const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ - { - name: 'rt', - type: 'keyword', - script: 'emit("x")', - format: { id: 'string' }, - }, - ]; - const result = toStoredFieldFormats(runtimeFields); - expect(result).toEqual({ - rt: { id: 'string' }, - }); - }); - - it('omits entries when format is missing', () => { - const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ - { name: 'rt', type: 'keyword', script: 'emit("x")' }, - ]; - const result = toStoredFieldFormats(runtimeFields); - expect(result).toEqual({}); - }); - - it('returns undefined when runtimeFields is undefined (default)', () => { - expect(toStoredFieldFormats()).toBeUndefined(); - }); - - it('returns undefined when runtimeFields is empty array', () => { - expect(toStoredFieldFormats([])).toBeUndefined(); - }); - - it('includes only runtime fields that have format', () => { - const runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [ - { name: 'a', type: 'keyword', format: { id: 'url' } }, - { name: 'b', type: 'long' }, - { name: 'c', type: 'date', format: { id: 'date' } }, - ]; - const result = toStoredFieldFormats(runtimeFields); - expect(result).toEqual({ - a: { id: 'url' }, - c: { id: 'date' }, - }); - }); - }); - describe('fromStoredTab', () => { it('converts stored tab with dataView id to API tab', () => { const storedTab = { diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts index 70a59d6cddd5e..f0f2fa3cd0c12 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -10,13 +10,19 @@ 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 { DataViewSpec, SerializedSearchSourceFields } from '@kbn/data-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 { + fromStoredRuntimeFields, + toStoredFieldAttributes, + toStoredFieldFormats, + toStoredRuntimeFields, +} 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'; @@ -27,7 +33,6 @@ import { import type { DiscoverSessionClassicTab, DiscoverSessionDataset, - DiscoverSessionDataViewSpec, DiscoverSessionEmbeddableByReferenceState, DiscoverSessionEmbeddableByValueState, DiscoverSessionEmbeddableState, @@ -331,7 +336,11 @@ export function fromStoredDataset( type: 'index', index: title, time_field: index.timeFieldName, - runtime_fields: fromStoredRuntimeFields(index.runtimeFieldMap, index.fieldFormats), + runtime_fields: fromStoredRuntimeFields( + index.runtimeFieldMap, + index.fieldFormats, + index.fieldAttrs + ), }; } @@ -341,49 +350,12 @@ export function toStoredDataset( if (dataset.type === 'dataView') return dataset.id; const runtimeFieldMap = toStoredRuntimeFields(dataset.runtime_fields); const fieldFormats = toStoredFieldFormats(dataset.runtime_fields); + const fieldAttrs = toStoredFieldAttributes(dataset.runtime_fields); return { title: dataset.index, timeFieldName: dataset.time_field, ...(runtimeFieldMap && Object.keys(runtimeFieldMap).length > 0 && { runtimeFieldMap }), ...(fieldFormats && Object.keys(fieldFormats).length > 0 && { fieldFormats }), + ...(fieldAttrs && Object.keys(fieldAttrs).length > 0 && { fieldAttrs }), }; } - -export function fromStoredRuntimeFields( - runtimeFields: DataViewSpec['runtimeFieldMap'] = {}, - fieldFormats: DataViewSpec['fieldFormats'] = {} -): DiscoverSessionDataViewSpec['runtime_fields'] { - return Object.keys(runtimeFields).map((name) => ({ - type: runtimeFields?.[name].type, - name, - script: runtimeFields?.[name].script?.source, - format: fieldFormats?.[name], - })); -} - -export function toStoredRuntimeFields( - runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [] -): DataViewSpec['runtimeFieldMap'] { - if (!runtimeFields || runtimeFields.length === 0) return {}; - return runtimeFields.reduce((acc, { name, type, script }) => { - return { - ...acc, - [name]: { - type, - ...(script && { script: { source: script } }), - }, - }; - }, {}); -} - -export function toStoredFieldFormats( - runtimeFields: DiscoverSessionDataViewSpec['runtime_fields'] = [] -): DataViewSpec['fieldFormats'] { - if (!runtimeFields || runtimeFields.length === 0) return undefined; - return runtimeFields.reduce((acc, { name, format }) => { - return { - ...acc, - ...(format ? { [name]: format } : {}), - }; - }, {}); -} diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index 1f71a7f59a19a..e9fa736f00b34 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -129,6 +129,8 @@ 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' diff --git a/src/platform/plugins/shared/discover/server/embeddable/schema.ts b/src/platform/plugins/shared/discover/server/embeddable/schema.ts index d67ae5c387030..e3b792641ec44 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -17,6 +17,7 @@ import { } 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 type { GetDrilldownsSchemaFnType } from '@kbn/embeddable-plugin/server'; import { ON_OPEN_PANEL_MENU } from '@kbn/ui-actions-plugin/common/trigger_ids'; @@ -95,67 +96,7 @@ export const dataViewSpecSchema = schema.object( * 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( - schema.object({ - /** - * The type of the runtime field (e.g., 'keyword', 'long', 'date'). - * Example: 'keyword' - */ - type: schema.oneOf( - [ - schema.literal('keyword'), - schema.literal('long'), - schema.literal('double'), - schema.literal('date'), - schema.literal('ip'), - schema.literal('boolean'), - schema.literal('geo_point'), - schema.literal('composite'), - ], - { - meta: { - description: 'The type of the runtime field (e.g., "keyword", "long", "date").', - }, - } - ), - /** - * The name of the runtime field. - * Example: 'my_runtime_field' - */ - name: schema.string({ - meta: { - description: 'The name of the runtime field. Example: "my_runtime_field".', - }, - }), - /** - * The script that defines the runtime field. This should be a painless script that computes the field value at query time. - * Example: 'emit(doc["field_name"].value * 2);' - */ - script: schema.maybe( - schema.string({ - meta: { - description: - 'The script that defines the runtime field. This should be a painless script that computes the field value at query time.', - }, - }) - ), - /** - * Optional format definition for the runtime field. The structure depends on the field type and use case. - * If not provided, no format is applied. - */ - format: schema.maybe( - schema.any({ - meta: { - description: - 'Optional format definition for the runtime field. The structure depends on the field type and use case.', - }, - }) - ), - }), - { maxSize: 100 } - ) - ), + runtime_fields: schema.maybe(schema.arrayOf(runtimeFieldSchema, { maxSize: 100 })), }, { meta: { id: 'indexDatasetTypeSchema' } } ); diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 2b9747405350d..77a1e0255d8f9 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -124,6 +124,8 @@ "@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", From 78dff1ce4e4b9b59fd4a71375088c2e287af656e Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Thu, 26 Mar 2026 15:32:03 -0700 Subject: [PATCH 26/33] shared as-code data_source schema and transforms --- .../shared/as-code/data-views-schema/index.ts | 13 +- .../data-views-schema/src/constants.ts | 14 ++ .../data-views-schema/src/schema_data_view.ts | 52 +++++ .../as-code/data-views-schema/src/types.ts | 8 + .../as-code/data-views-transforms/index.ts | 2 + .../src/from_stored_data_view.ts | 44 ++++ .../src/stored_data_source.test.ts | 169 +++++++++++++++ .../src/to_stored_data_view.ts | 40 ++++ .../search_embeddable_transforms.test.ts | 11 +- .../common/embeddable/transform_utils.test.ts | 192 ++---------------- .../common/embeddable/transform_utils.ts | 51 +---- ...et_legacy_log_stream_embeddable_factory.ts | 7 +- .../utils/serialization_utils.test.ts | 5 +- .../discover/server/embeddable/index.ts | 3 - .../discover/server/embeddable/schema.ts | 64 +----- .../plugins/shared/discover/server/index.ts | 3 - 16 files changed, 379 insertions(+), 299 deletions(-) create mode 100644 src/platform/packages/shared/as-code/data-views-schema/src/constants.ts create mode 100644 src/platform/packages/shared/as-code/data-views-schema/src/schema_data_view.ts create mode 100644 src/platform/packages/shared/as-code/data-views-transforms/src/from_stored_data_view.ts create mode 100644 src/platform/packages/shared/as-code/data-views-transforms/src/stored_data_source.test.ts create mode 100644 src/platform/packages/shared/as-code/data-views-transforms/src/to_stored_data_view.ts 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..dcb5dba8f900f --- /dev/null +++ b/src/platform/packages/shared/as-code/data-views-transforms/src/from_stored_data_view.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 == null) throw new Error('Data view is required to convert from stored data view'); + if (typeof index === 'string') return { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: index }; + const title = index.title ?? index.id; + if (title == null || title === '') { + throw new Error('Stored index object must have a title or id to convert to data view'); + } + return { + type: AS_CODE_DATA_VIEW_SPEC_TYPE, + index_pattern: 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..12ad94073d671 --- /dev/null +++ b/src/platform/packages/shared/as-code/data-views-transforms/src/stored_data_source.test.ts @@ -0,0 +1,169 @@ +/* + * 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 AsCodeDataViewReference, + type AsCodeDataViewSpec, +} from '@kbn/as-code-data-views-schema'; +import { fromStoredDataView } from './from_stored_data_view'; +import { toStoredDataView } from './to_stored_data_view'; + +describe('fromStoredDataView', () => { + it('throws when index is null', () => { + expect(() => fromStoredDataView(null as unknown as string)).toThrow( + 'Data view is required to convert from stored data view' + ); + }); + + it('returns data_view_reference when index is a string id', () => { + const result = fromStoredDataView('my-data-view-id'); + expect(result).toEqual({ type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'my-data-view-id' }); + }); + + it('throws when index object has no title or id', () => { + expect(() => fromStoredDataView({ timeFieldName: '@timestamp' } as unknown as string)).toThrow( + 'Stored index object must have a title or id to convert to data view' + ); + }); + + it('transforms index-pattern object to AsCodeDataViewSpec', () => { + const index: DataViewSpec = { + id: 'eaa3802b-a071-49c0-8442-1fcd2cdcc9fa', + title: 'f*', + timeFieldName: '@timestamp', + sourceFilters: [], + fieldFormats: { + foobar: { + id: 'url', + params: { + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/app/dashboards', + basePath: '', + }, + type: 'a', + urlTemplate: 'http://google.com?q={{value}}', + labelTemplate: 'google search for {{value}}', + width: null, + height: null, + }, + }, + }, + runtimeFieldMap: { + foobar: { + type: 'keyword', + script: { + source: 'emit(UUID.randomUUID().toString())', + }, + }, + }, + fieldAttrs: { + foobar: { + customLabel: 'my custom label', + customDescription: 'my custom description', + }, + }, + allowNoIndex: false, + name: 'f*', + allowHidden: false, + managed: false, + }; + const expected: AsCodeDataViewSpec = { + type: AS_CODE_DATA_VIEW_SPEC_TYPE, + index_pattern: 'f*', + time_field: '@timestamp', + runtime_fields: [ + { + type: 'keyword', + name: 'foobar', + script: 'emit(UUID.randomUUID().toString())', + format: { + type: 'url', + params: { + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/app/dashboards', + basePath: '', + }, + type: 'a', + urlTemplate: 'http://google.com?q={{value}}', + labelTemplate: 'google search for {{value}}', + width: null, + height: null, + }, + }, + custom_label: 'my custom label', + custom_description: 'my custom description', + }, + ], + }; + const result = fromStoredDataView(index); + expect(result).toEqual(expected); + }); +}); + +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/plugins/shared/discover/common/embeddable/search_embeddable_transforms.test.ts b/src/platform/plugins/shared/discover/common/embeddable/search_embeddable_transforms.test.ts index e2b0db02144aa..31b715a88fb23 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,6 +7,7 @@ * 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 { @@ -129,13 +130,13 @@ describe('searchEmbeddableTransforms', () => { sort, view_mode: viewMode, density, - dataset, + 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(dataset).toEqual({ - type: 'dataView', + expect(dataSource).toEqual({ + type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-1', }); expect(mockDrilldownTransforms.transformOut).toHaveBeenCalledWith(state, references); @@ -240,7 +241,7 @@ describe('searchEmbeddableTransforms', () => { filters: [], rows_per_page: 100, sample_size: 1000, - dataset: { type: 'dataView', id: 'data-view-1' }, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-1' }, }, ], }; @@ -278,7 +279,7 @@ describe('searchEmbeddableTransforms', () => { row_height: 3, query: { language: 'kuery', query: '' }, filters: [], - dataset: { type: 'dataView', id: 'data-view-id-123' }, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-id-123' }, }, ], }; 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 index b04c6beedd9f9..345ee2a11865c 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -7,6 +7,10 @@ * 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 { byReferenceDiscoverSessionToSavedSearchEmbeddableState, @@ -14,14 +18,12 @@ import { byValueDiscoverSessionToSavedSearchEmbeddableState, byValueSavedSearchToDiscoverSessionEmbeddableState, discoverSessionToSavedSearchEmbeddableState, - fromStoredDataset, fromStoredGrid, fromStoredHeight, fromStoredSearchEmbeddableState, fromStoredSort, fromStoredTab, savedSearchToDiscoverSessionEmbeddableState, - toStoredDataset, toStoredGrid, toStoredHeight, toStoredSearchEmbeddableState, @@ -41,11 +43,6 @@ import type { DiscoverSessionEmbeddableByValueState, } from '../../server'; import { DataGridDensity } from '@kbn/discover-utils'; -import type { - DiscoverSessionDataViewReference, - DiscoverSessionDataViewSpec, -} from '../../server/embeddable'; -import type { DataViewSpec } from '@kbn/data-views-plugin/common'; import { ASCODE_FILTER_OPERATOR, ASCODE_FILTER_TYPE } from '@kbn/as-code-filters-constants'; describe('search embeddable transform utils', () => { @@ -170,7 +167,7 @@ describe('search embeddable transform utils', () => { expect('tabs' in result && result.tabs).toBeDefined(); expect('tabs' in result && result.tabs?.[0]).toMatchObject({ - dataset: { type: 'dataView', id: dataViewId }, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: dataViewId }, }); }); }); @@ -212,7 +209,7 @@ describe('search embeddable transform utils', () => { row_height: 'auto', query: { language: 'kuery', query: '' }, filters: [], - dataset: { type: 'dataView', id: 'data-view-1' }, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-1' }, }, ], }; @@ -293,8 +290,8 @@ describe('search embeddable transform utils', () => { view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, header_row_height: 3, - dataset: { - type: 'dataView', + data_source: { + type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'c7d7a1f5-19da-4ba9-af15-5919e8cd2528', }, }, @@ -483,7 +480,7 @@ describe('search embeddable transform utils', () => { filters: [], rows_per_page: 100, sample_size: 1000, - dataset: { type: 'dataView', id: 'data-view-1' }, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-1' }, }, ], }; @@ -530,9 +527,9 @@ describe('search embeddable transform utils', () => { filters: [], rows_per_page: 25, sample_size: 500, - dataset: { - type: 'index', - index: 'my-*', + data_source: { + type: AS_CODE_DATA_VIEW_SPEC_TYPE, + index_pattern: 'my-*', time_field: '@timestamp', runtime_fields: [ { @@ -957,157 +954,6 @@ describe('search embeddable transform utils', () => { }); }); - describe('fromStoredDataset', () => { - it('throws when index is null', () => { - expect(() => fromStoredDataset(null as unknown as string)).toThrow( - 'Data view is required to convert from stored dataset' - ); - }); - - it('returns dataView reference when index is a string id', () => { - const result = fromStoredDataset('my-data-view-id'); - expect(result).toEqual({ type: 'dataView', id: 'my-data-view-id' }); - }); - - it('throws when index object has no title or id', () => { - expect(() => fromStoredDataset({ timeFieldName: '@timestamp' } as unknown as string)).toThrow( - 'Stored index object must have a title or id to convert to dataset' - ); - }); - - it('transforms index-pattern object to DiscoverSessionDataViewSpec', () => { - const index: DataViewSpec = { - id: 'eaa3802b-a071-49c0-8442-1fcd2cdcc9fa', - title: 'f*', - timeFieldName: '@timestamp', - sourceFilters: [], - fieldFormats: { - foobar: { - id: 'url', - params: { - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/app/dashboards', - basePath: '', - }, - type: 'a', - urlTemplate: 'http://google.com?q={{value}}', - labelTemplate: 'google search for {{value}}', - width: null, - height: null, - }, - }, - }, - runtimeFieldMap: { - foobar: { - type: 'keyword', - script: { - source: 'emit(UUID.randomUUID().toString())', - }, - }, - }, - fieldAttrs: { - foobar: { - customLabel: 'my custom label', - customDescription: 'my custom description', - }, - }, - allowNoIndex: false, - name: 'f*', - allowHidden: false, - managed: false, - }; - const expected: DiscoverSessionDataViewSpec = { - type: 'index', - index: 'f*', - time_field: '@timestamp', - runtime_fields: [ - { - type: 'keyword', - name: 'foobar', - script: 'emit(UUID.randomUUID().toString())', - format: { - type: 'url', - params: { - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/app/dashboards', - basePath: '', - }, - type: 'a', - urlTemplate: 'http://google.com?q={{value}}', - labelTemplate: 'google search for {{value}}', - width: null, - height: null, - }, - }, - custom_label: 'my custom label', - custom_description: 'my custom description', - }, - ], - }; - const result = fromStoredDataset(index); - expect(result).toEqual(expected); - }); - }); - - describe('toStoredDataset', () => { - it('converts dataView dataset to string id', () => { - const dataset: DiscoverSessionDataViewReference = { - type: 'dataView', - id: 'my-data-view-id', - }; - const result = toStoredDataset(dataset); - expect(result).toBe('my-data-view-id'); - }); - - it('converts index-pattern dataset to serialized index spec', () => { - const dataset: DiscoverSessionDataViewSpec = { - type: 'index', - index: 'my-index-*', - time_field: '@timestamp', - runtime_fields: [ - { - name: 'rt', - type: 'keyword', - script: 'emit(doc["id"].value)', - format: { type: 'string' }, - }, - ], - }; - const result = toStoredDataset(dataset); - 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 dataset without runtime fields', () => { - const dataset: DiscoverSessionDataViewSpec = { - type: 'index', - index: 'logs-*', - time_field: '@timestamp', - }; - const result = toStoredDataset(dataset); - expect(result).toEqual({ - title: 'logs-*', - timeFieldName: '@timestamp', - }); - }); - }); - describe('fromStoredTab', () => { it('converts stored tab with dataView id to API tab', () => { const storedTab = { @@ -1147,8 +993,8 @@ describe('search embeddable transform utils', () => { expect(result.row_height).toBe('auto'); expect(result.header_row_height).toBe('auto'); expect(result.density).toBe(DataGridDensity.COMPACT); - expect('dataset' in result && result.dataset).toEqual({ - type: 'dataView', + 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); @@ -1169,7 +1015,7 @@ describe('search embeddable transform utils', () => { filters: [], rows_per_page: 100, sample_size: 500, - dataset: { type: 'dataView', id: 'data-view-1' }, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-1' }, }; const { state, references } = toStoredTab(apiTab); expect(references).toContainEqual({ @@ -1191,7 +1037,7 @@ describe('search embeddable transform utils', () => { expect(searchSource.filter).toEqual([]); }); - it('converts API tab with index-pattern dataset (no refs) when inline', () => { + it('converts API tab with index-pattern data_source (no refs) when inline', () => { const apiTab: DiscoverSessionEmbeddableByValueState['tabs'][0] = { column_order: ['foo'], sort: [], @@ -1201,9 +1047,9 @@ describe('search embeddable transform utils', () => { row_height: 3, query: { language: 'kuery', query: '' }, filters: [], - dataset: { - type: 'index', - index: 'my-*', + data_source: { + type: AS_CODE_DATA_VIEW_SPEC_TYPE, + index_pattern: 'my-*', time_field: '@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 index f0f2fa3cd0c12..d8a274ad7d7c0 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -17,12 +17,7 @@ import { parseSearchSourceJSON, } from '@kbn/data-plugin/common'; import { fromStoredFilters, toStoredFilters } from '@kbn/as-code-filters-transforms'; -import { - fromStoredRuntimeFields, - toStoredFieldAttributes, - toStoredFieldFormats, - toStoredRuntimeFields, -} from '@kbn/as-code-data-views-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'; @@ -32,7 +27,6 @@ import { } from './type_guards'; import type { DiscoverSessionClassicTab, - DiscoverSessionDataset, DiscoverSessionEmbeddableByReferenceState, DiscoverSessionEmbeddableByValueState, DiscoverSessionEmbeddableState, @@ -209,7 +203,7 @@ export function fromStoredTab( }), query, filters: fromStoredFilters(filter) ?? [], - dataset: fromStoredDataset(index), + data_source: fromStoredDataView(index), view_mode: viewMode ?? VIEW_MODE.DOCUMENT_LEVEL, }; } @@ -222,7 +216,7 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { const searchSourceValues: SerializedSearchSourceFields = { query: apiTab.query, ...('filters' in apiTab && { filter: toStoredFilters(apiTab.filters) }), - ...('dataset' in apiTab && { index: toStoredDataset(apiTab.dataset) }), + ...('data_source' in apiTab && { index: toStoredDataView(apiTab.data_source) }), }; const [searchSourceFields, references] = extractReferences(searchSourceValues); const state: DiscoverSessionTabAttributes = { @@ -231,7 +225,7 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { columns: columnOrder ?? [], grid: toStoredGrid(columnSettings), hideChart: false, - isTextBasedQuery: !('dataset' in apiTab), + isTextBasedQuery: !('data_source' in apiTab), kibanaSavedObjectMeta: { searchSourceJSON: JSON.stringify(searchSourceFields) }, ...('view_mode' in apiTab && { viewMode: apiTab.view_mode }), }; @@ -322,40 +316,3 @@ export function toStoredHeight( ): number { return typeof height === 'number' ? height : -1; // -1 === 'auto' } - -export function fromStoredDataset( - index: SerializedSearchSourceFields['index'] -): DiscoverSessionDataset { - if (index == null) throw new Error('Data view is required to convert from stored dataset'); - if (typeof index === 'string') return { type: 'dataView', id: index }; - const title = index.title ?? index.id; - if (title == null || title === '') { - throw new Error('Stored index object must have a title or id to convert to dataset'); - } - return { - type: 'index', - index: title, - time_field: index.timeFieldName, - runtime_fields: fromStoredRuntimeFields( - index.runtimeFieldMap, - index.fieldFormats, - index.fieldAttrs - ), - }; -} - -export function toStoredDataset( - dataset: DiscoverSessionDataset -): SerializedSearchSourceFields['index'] { - if (dataset.type === 'dataView') return dataset.id; - const runtimeFieldMap = toStoredRuntimeFields(dataset.runtime_fields); - const fieldFormats = toStoredFieldFormats(dataset.runtime_fields); - const fieldAttrs = toStoredFieldAttributes(dataset.runtime_fields); - return { - title: dataset.index, - timeFieldName: dataset.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/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 5cccf7c29b145..e5d6abbd321c9 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 @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { AS_CODE_DATA_VIEW_SPEC_TYPE } from '@kbn/as-code-data-views-schema'; import type { DiscoverSessionEmbeddableState } from '../../server'; import { getSearchEmbeddableFactory } from './get_search_embeddable_factory'; import { LEGACY_LOG_STREAM_EMBEDDABLE } from './constants'; @@ -22,9 +23,9 @@ export const getLegacyLogStreamEmbeddableFactory = ( ...logsInitialState, tabs: [ { - dataset: { - type: 'index', - index: discoverServices.logsDataAccess + data_source: { + type: AS_CODE_DATA_VIEW_SPEC_TYPE, + index_pattern: discoverServices.logsDataAccess ? await discoverServices.logsDataAccess.services.logSourcesService.getFlattenedLogSources() : 'logs-*-*', time_field: '@timestamp', 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 7b1be922174a3..c4500ecc6ac8b 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,6 +7,7 @@ * 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 { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; @@ -89,7 +90,7 @@ describe('Serialization utils', () => { filters: [], rows_per_page: 100, sample_size: 100, - dataset: { type: 'dataView', id: dataViewId }, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: dataViewId }, }, ], }; @@ -343,7 +344,7 @@ describe('Serialization utils', () => { sort: [{ name: 'order_date', direction: 'desc' }], view_mode: VIEW_MODE.DOCUMENT_LEVEL, density: DataGridDensity.COMPACT, - dataset: { type: 'dataView', id: dataViewId }, + data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: dataViewId }, }), ], }); diff --git a/src/platform/plugins/shared/discover/server/embeddable/index.ts b/src/platform/plugins/shared/discover/server/embeddable/index.ts index ba28365d69276..b725929a76736 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/index.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/index.ts @@ -9,9 +9,6 @@ export { createSearchEmbeddableFactory } from './search_embeddable_factory'; export type { - DiscoverSessionDataViewReference, - DiscoverSessionDataViewSpec, - DiscoverSessionDataset, DiscoverSessionClassicTab, DiscoverSessionEsqlTab, DiscoverSessionTab, diff --git a/src/platform/plugins/shared/discover/server/embeddable/schema.ts b/src/platform/plugins/shared/discover/server/embeddable/schema.ts index e3b792641ec44..faeb76f980de9 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -17,7 +17,7 @@ import { } 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'; @@ -46,63 +46,6 @@ 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( - { - 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' } } -); - -export const dataViewSpecSchema = 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({ - meta: { - description: - 'The name of the time field in the index. Used for time-based filtering. Example: "@timestamp".', - }, - }) - ), - /** - * 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]); - export const viewModeSchema = schema.oneOf( [ schema.literal(VIEW_MODE.DOCUMENT_LEVEL), @@ -374,7 +317,7 @@ const classicTabSchema = schema.allOf([ description: 'List of filters to apply to the data in the tab.', }, }), - dataset: dataViewSchema, + data_source: dataViewSchema, view_mode: viewModeSchema, }), ]); @@ -445,9 +388,6 @@ export const getDiscoverSessionEmbeddableSchema = ( getDiscoverSessionByReferenceEmbeddableSchema(getDrilldownsSchema), ]); -export type DiscoverSessionDataViewReference = TypeOf; -export type DiscoverSessionDataViewSpec = TypeOf; -export type DiscoverSessionDataset = TypeOf; export type DiscoverSessionPanelOverrides = TypeOf; export type DiscoverSessionClassicTab = TypeOf; export type DiscoverSessionEsqlTab = TypeOf; diff --git a/src/platform/plugins/shared/discover/server/index.ts b/src/platform/plugins/shared/discover/server/index.ts index 1a3b047c537cb..38793c79bdb04 100644 --- a/src/platform/plugins/shared/discover/server/index.ts +++ b/src/platform/plugins/shared/discover/server/index.ts @@ -41,9 +41,6 @@ export interface DiscoverServerPluginStart { export { config } from './config'; export type { - DiscoverSessionDataViewReference, - DiscoverSessionDataViewSpec, - DiscoverSessionDataset, DiscoverSessionClassicTab, DiscoverSessionEsqlTab, DiscoverSessionTab, From 043f4f4a0618c1b86ddfd4d824e7e4ebe8657d14 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Fri, 27 Mar 2026 10:38:14 -0700 Subject: [PATCH 27/33] Add by-value/by-ref schema meta to Discover session embeddable schema --- .../discover/server/embeddable/schema.ts | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/platform/plugins/shared/discover/server/embeddable/schema.ts b/src/platform/plugins/shared/discover/server/embeddable/schema.ts index faeb76f980de9..bae1800a199d6 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -12,6 +12,8 @@ import { schema } from '@kbn/config-schema'; import { DataGridDensity } from '@kbn/discover-utils'; import { aggregateQuerySchema, querySchema } from '@kbn/es-query-server'; import { + BY_REF_SCHEMA_META, + BY_VALUE_SCHEMA_META, serializedTitlesSchema, serializedTimeRangeSchema, } from '@kbn/presentation-publishing-schemas'; @@ -342,14 +344,20 @@ const DISCOVER_SUPPORTED_DRILLDOWN_TRIGGERS = [ON_OPEN_PANEL_MENU]; * 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

) { +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, - ]); + schema.allOf( + [ + serializedTitlesSchema, + serializedTimeRangeSchema, + getDrilldownsSchema(DISCOVER_SUPPORTED_DRILLDOWN_TRIGGERS), + embeddableSchema, + ], + allOfOptions ?? {} + ); } const getDiscoverSessionByValueEmbeddableSchema = withPanelSchemas( @@ -362,7 +370,8 @@ const getDiscoverSessionByValueEmbeddableSchema = withPanelSchemas( 'Inline tab configuration. Used when no `discover_session_id` is set. Panel-level fields (e.g. `column_order`, `sort`) override these when provided. Currently supports one tab.', }, }), - }) + }), + { meta: BY_VALUE_SCHEMA_META } ); const getDiscoverSessionByReferenceEmbeddableSchema = withPanelSchemas( @@ -377,7 +386,8 @@ const getDiscoverSessionByReferenceEmbeddableSchema = withPanelSchemas( }) ), overrides: panelOverridesSchema, - }) + }), + { meta: BY_REF_SCHEMA_META } ); export const getDiscoverSessionEmbeddableSchema = ( From 0d17cd04effbca2d4534b2655b7371932ad6ee73 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Mon, 30 Mar 2026 22:22:26 -0700 Subject: [PATCH 28/33] Review feedback --- .../src/from_stored_data_view.ts | 10 +- .../src/stored_data_source.test.ts | 96 ------------- .../common/embeddable/get_transform_in.ts | 11 +- .../common/embeddable/get_transform_out.ts | 8 +- .../discover/common/embeddable/index.ts | 14 +- .../common/embeddable/transform_utils.test.ts | 127 ++++++++---------- .../common/embeddable/transform_utils.ts | 46 +++---- .../discover/common/embeddable/type_guards.ts | 6 +- .../discover/public/__mocks__/services.ts | 2 +- .../embeddable/utils/serialization_utils.ts | 31 ++--- .../plugins/shared/discover/public/plugin.tsx | 19 +-- .../plugins/shared/discover/server/plugin.ts | 36 +++-- 12 files changed, 140 insertions(+), 266 deletions(-) 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 index dcb5dba8f900f..99bcddf0affb3 100644 --- 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 @@ -25,12 +25,12 @@ import { fromStoredRuntimeFields } from './from_stored_runtime_fields'; export function fromStoredDataView( index: string | DataViewSpec | null | undefined ): AsCodeDataView { - if (index == null) throw new Error('Data view is required to convert from stored data view'); - if (typeof index === 'string') return { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: index }; - const title = index.title ?? index.id; - if (title == null || title === '') { - throw new Error('Stored index object must have a title or id to convert to data view'); + 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 }; } + const title = index.title ?? index.id; + if (!title) throw new Error('Cannot derive data view without `title` or `id`'); return { type: AS_CODE_DATA_VIEW_SPEC_TYPE, index_pattern: title, 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 index 12ad94073d671..d3e61ca134981 100644 --- 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 @@ -7,110 +7,14 @@ * 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 AsCodeDataViewReference, type AsCodeDataViewSpec, } from '@kbn/as-code-data-views-schema'; -import { fromStoredDataView } from './from_stored_data_view'; import { toStoredDataView } from './to_stored_data_view'; -describe('fromStoredDataView', () => { - it('throws when index is null', () => { - expect(() => fromStoredDataView(null as unknown as string)).toThrow( - 'Data view is required to convert from stored data view' - ); - }); - - it('returns data_view_reference when index is a string id', () => { - const result = fromStoredDataView('my-data-view-id'); - expect(result).toEqual({ type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'my-data-view-id' }); - }); - - it('throws when index object has no title or id', () => { - expect(() => fromStoredDataView({ timeFieldName: '@timestamp' } as unknown as string)).toThrow( - 'Stored index object must have a title or id to convert to data view' - ); - }); - - it('transforms index-pattern object to AsCodeDataViewSpec', () => { - const index: DataViewSpec = { - id: 'eaa3802b-a071-49c0-8442-1fcd2cdcc9fa', - title: 'f*', - timeFieldName: '@timestamp', - sourceFilters: [], - fieldFormats: { - foobar: { - id: 'url', - params: { - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/app/dashboards', - basePath: '', - }, - type: 'a', - urlTemplate: 'http://google.com?q={{value}}', - labelTemplate: 'google search for {{value}}', - width: null, - height: null, - }, - }, - }, - runtimeFieldMap: { - foobar: { - type: 'keyword', - script: { - source: 'emit(UUID.randomUUID().toString())', - }, - }, - }, - fieldAttrs: { - foobar: { - customLabel: 'my custom label', - customDescription: 'my custom description', - }, - }, - allowNoIndex: false, - name: 'f*', - allowHidden: false, - managed: false, - }; - const expected: AsCodeDataViewSpec = { - type: AS_CODE_DATA_VIEW_SPEC_TYPE, - index_pattern: 'f*', - time_field: '@timestamp', - runtime_fields: [ - { - type: 'keyword', - name: 'foobar', - script: 'emit(UUID.randomUUID().toString())', - format: { - type: 'url', - params: { - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/app/dashboards', - basePath: '', - }, - type: 'a', - urlTemplate: 'http://google.com?q={{value}}', - labelTemplate: 'google search for {{value}}', - width: null, - height: null, - }, - }, - custom_label: 'my custom label', - custom_description: 'my custom description', - }, - ], - }; - const result = fromStoredDataView(index); - expect(result).toEqual(expected); - }); -}); - describe('toStoredDataView', () => { it('converts data_view_reference data_source to string id', () => { const dataView: AsCodeDataViewReference = { 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 50c299cb44e23..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 @@ -12,11 +12,8 @@ import { extractReferences, parseSearchSourceJSON } from '@kbn/data-plugin/commo 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 { - isByValueSavedSearchEmbeddableState, - isSearchEmbeddableLegacyPanelState, -} from './type_guards'; -import { discoverSessionToSavedSearchEmbeddableState } from './transform_utils'; +import { isSearchEmbeddableByValueState, isSearchEmbeddableLegacyPanelState } from './type_guards'; +import { toStoredSearchEmbeddable } from './transform_utils'; import type { SearchEmbeddablePanelApiState, SearchEmbeddableState, @@ -31,7 +28,7 @@ export function getTransformIn(transformDrilldownsIn: DrilldownTransforms['trans const { state, references } = transformDrilldownsIn(apiState); return isSearchEmbeddableLegacyPanelState(state) ? legacyTransformIn(state, references) - : discoverSessionToSavedSearchEmbeddableState(state, references); + : toStoredSearchEmbeddable(state, references); }; } @@ -39,7 +36,7 @@ function legacyTransformIn( storedState: SearchEmbeddableState, drilldownReferences: SavedObjectReference[] = [] ): { state: StoredSearchEmbeddableState; references: SavedObjectReference[] } { - if (!isByValueSavedSearchEmbeddableState(storedState)) { + if (!isSearchEmbeddableByValueState(storedState)) { const { savedObjectId, ...rest } = storedState; return { state: rest, 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 973d80c53610e..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 @@ -19,8 +19,8 @@ import type { SearchEmbeddableState, StoredSearchEmbeddableState, } from './types'; -import { isByValueSavedSearchEmbeddableState } from './type_guards'; -import { savedSearchToDiscoverSessionEmbeddableState } from './transform_utils'; +import { isSearchEmbeddableByValueState } from './type_guards'; +import { fromStoredSearchEmbeddable } from './transform_utils'; export function getTransformOut( transformDrilldownsOut: DrilldownTransforms['transformOut'], @@ -38,7 +38,7 @@ export function getTransformOut( const state = transformsFlow(storedState); return !isEmbeddableTransformsEnabled() ? legacyTransformOut(state, references) - : savedSearchToDiscoverSessionEmbeddableState(state, references); + : fromStoredSearchEmbeddable(state, references); }; } @@ -46,7 +46,7 @@ function legacyTransformOut( state: StoredSearchEmbeddableState, references: SavedObjectReference[] | undefined ): SearchEmbeddableState { - if (isByValueSavedSearchEmbeddableState(state)) { + if (isSearchEmbeddableByValueState(state)) { const tabsState = { ...state, attributes: extractTabs(state.attributes) }; const tabs = tabsState.attributes.tabs.map((tab) => { try { diff --git a/src/platform/plugins/shared/discover/common/embeddable/index.ts b/src/platform/plugins/shared/discover/common/embeddable/index.ts index c93606caa2715..555be16a6f79b 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/index.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/index.ts @@ -12,14 +12,14 @@ export { type SearchEmbeddablePanelApiState, } from './search_embeddable_transforms'; export { - isByReferenceDiscoverSessionEmbeddableState, + isDiscoverSessionEmbeddableByReferenceState, isSearchEmbeddableLegacyPanelState, } from './type_guards'; export { - byReferenceSavedSearchToDiscoverSessionEmbeddableState, - byValueDiscoverSessionToSavedSearchEmbeddableState, - byValueSavedSearchToDiscoverSessionEmbeddableState, - discoverSessionToSavedSearchEmbeddableState, - savedSearchToDiscoverSessionEmbeddableState, - toStoredSearchEmbeddableState, + fromStoredSearchEmbeddable, + fromStoredSearchEmbeddableByRef, + fromStoredSearchEmbeddableByValue, + toStoredSearchEmbeddable, + toStoredSearchEmbeddableByValue, + fromDiscoverSessionPanelOverrides, } from './transform_utils'; 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 index 345ee2a11865c..df1432e6124e6 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -13,20 +13,20 @@ import { } from '@kbn/as-code-data-views-schema'; import type { SavedObjectReference } from '@kbn/core-saved-objects-common/src/server_types'; import { - byReferenceDiscoverSessionToSavedSearchEmbeddableState, - byReferenceSavedSearchToDiscoverSessionEmbeddableState, - byValueDiscoverSessionToSavedSearchEmbeddableState, - byValueSavedSearchToDiscoverSessionEmbeddableState, - discoverSessionToSavedSearchEmbeddableState, + fromStoredSearchEmbeddable, + fromStoredSearchEmbeddableByRef, + fromStoredSearchEmbeddableByValue, fromStoredGrid, fromStoredHeight, - fromStoredSearchEmbeddableState, + toDiscoverSessionPanelOverrides, fromStoredSort, fromStoredTab, - savedSearchToDiscoverSessionEmbeddableState, + toStoredSearchEmbeddable, + toStoredSearchEmbeddableByRef, + toStoredSearchEmbeddableByValue, toStoredGrid, toStoredHeight, - toStoredSearchEmbeddableState, + fromDiscoverSessionPanelOverrides, toStoredSort, toStoredTab, } from './transform_utils'; @@ -50,7 +50,7 @@ describe('search embeddable transform utils', () => { jest.clearAllMocks(); }); - describe('savedSearchToDiscoverSessionEmbeddableState', () => { + describe('fromStoredSearchEmbeddable', () => { it('dispatches to by-reference transform when state has no attributes', () => { const storedState: StoredSearchEmbeddableByReferenceState = { title: 'My Search', @@ -60,7 +60,7 @@ describe('search embeddable transform utils', () => { const references: SavedObjectReference[] = [ { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-123' }, ]; - const result = savedSearchToDiscoverSessionEmbeddableState(storedState, references); + const result = fromStoredSearchEmbeddable(storedState, references); expect(result).toMatchObject({ title: 'My Search', description: 'My description', @@ -112,7 +112,7 @@ describe('search embeddable transform utils', () => { const references: SavedObjectReference[] = [ { name: 'kibanaSavedObjectMeta.searchSourceJSON.index', type: 'index-pattern', id: 'dv-1' }, ]; - const result = savedSearchToDiscoverSessionEmbeddableState(storedState, references); + 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); @@ -163,7 +163,7 @@ describe('search embeddable transform utils', () => { }, } as StoredSearchEmbeddableByValueState; - const result = savedSearchToDiscoverSessionEmbeddableState(storedState); + const result = fromStoredSearchEmbeddable(storedState); expect('tabs' in result && result.tabs).toBeDefined(); expect('tabs' in result && result.tabs?.[0]).toMatchObject({ @@ -172,7 +172,7 @@ describe('search embeddable transform utils', () => { }); }); - describe('discoverSessionToSavedSearchEmbeddableState', () => { + describe('toStoredSearchEmbeddable', () => { it('dispatches to by-reference transform when state has discover_session_id', () => { const apiState: DiscoverSessionEmbeddableByReferenceState = { title: 'My Search', @@ -182,7 +182,7 @@ describe('search embeddable transform utils', () => { selected_tab_id: undefined, overrides: {}, }; - const { state, references } = discoverSessionToSavedSearchEmbeddableState(apiState); + const { state, references } = toStoredSearchEmbeddable(apiState); expect(references).toContainEqual({ name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, @@ -213,7 +213,7 @@ describe('search embeddable transform utils', () => { }, ], }; - const { state, references } = discoverSessionToSavedSearchEmbeddableState(apiState); + const { state, references } = toStoredSearchEmbeddable(apiState); expect(state).toHaveProperty('attributes'); expect((state as StoredSearchEmbeddableByValueState).attributes.tabs).toHaveLength(1); expect(references).toContainEqual({ @@ -224,8 +224,8 @@ describe('search embeddable transform utils', () => { }); }); - describe('byValueSavedSearchToDiscoverSessionEmbeddableState', () => { - it('converts to DiscoverSessionEmbeddableByValueState', () => { + describe('fromStoredSearchEmbeddableByValue', () => { + it('converts stored by-value SearchEmbeddable state to panel API shape', () => { const storedState: StoredSearchEmbeddableByValueState = { title: '[filebeat-*] elasticsearch logs', description: 'my description', @@ -298,14 +298,14 @@ describe('search embeddable transform utils', () => { ], }; - const result = byValueSavedSearchToDiscoverSessionEmbeddableState(storedState); + const result = fromStoredSearchEmbeddableByValue(storedState); expect(result).toEqual(expected); }); }); - describe('byReferenceSavedSearchToDiscoverSessionEmbeddableState', () => { - it('converts stored by-reference state to discover session embeddable state with references', () => { + describe('fromStoredSearchEmbeddableByRef', () => { + it('converts stored by-reference SearchEmbeddable state to panel API shape', () => { const storedSearch: StoredSearchEmbeddableByReferenceState = { title: 'My Saved Search', description: 'My description', @@ -314,10 +314,7 @@ describe('search embeddable transform utils', () => { const references: SavedObjectReference[] = [ { name: 'savedObjectRef', type: SavedSearchType, id: 'session-123' }, ]; - const result = byReferenceSavedSearchToDiscoverSessionEmbeddableState( - storedSearch, - references - ); + const result = fromStoredSearchEmbeddableByRef(storedSearch, references); expect(result).toEqual({ title: 'My Saved Search', description: 'My description', @@ -350,10 +347,7 @@ describe('search embeddable transform utils', () => { const references: SavedObjectReference[] = [ { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-xyz' }, ]; - const result = byReferenceSavedSearchToDiscoverSessionEmbeddableState( - storedSearch, - references - ); + const result = fromStoredSearchEmbeddableByRef(storedSearch, references); expect(result).toEqual({ title: 'My Saved Search', description: 'My description', @@ -380,11 +374,11 @@ describe('search embeddable transform utils', () => { const storedSearch: StoredSearchEmbeddableByReferenceState = { title: 'My Saved Search', }; + expect(() => fromStoredSearchEmbeddableByRef(storedSearch, [])).toThrow( + `Missing reference of type "${SavedSearchType}"` + ); expect(() => - byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedSearch, []) - ).toThrow(`Missing reference of type "${SavedSearchType}"`); - expect(() => - byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedSearch, [ + fromStoredSearchEmbeddableByRef(storedSearch, [ { name: 'wrongRefName', type: SavedSearchType, id: 'id-1' }, ]) ).toThrow(`Missing reference of type "${SavedSearchType}"`); @@ -398,10 +392,7 @@ describe('search embeddable transform utils', () => { { name: 'kibanaSavedObjectMeta.searchSourceJSON.index', type: 'index-pattern', id: 'dv-1' }, { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-picked' }, ]; - const result = byReferenceSavedSearchToDiscoverSessionEmbeddableState( - storedSearch, - references - ); + const result = fromStoredSearchEmbeddableByRef(storedSearch, references); expect(result.discover_session_id).toBe('session-picked'); }); @@ -410,7 +401,7 @@ describe('search embeddable transform utils', () => { title: 'Runtime / API state', savedObjectId: 'session-without-ref-array', }; - const result = byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedSearch, []); + const result = fromStoredSearchEmbeddableByRef(storedSearch, []); expect(result.discover_session_id).toBe('session-without-ref-array'); }); @@ -426,16 +417,13 @@ describe('search embeddable transform utils', () => { id: 'id-from-reference', }, ]; - const result = byReferenceSavedSearchToDiscoverSessionEmbeddableState( - storedSearch, - references - ); + const result = fromStoredSearchEmbeddableByRef(storedSearch, references); expect(result.discover_session_id).toBe('id-from-state'); }); }); - describe('byReferenceDiscoverSessionToSavedSearchEmbeddableState', () => { - it('converts discover session by-reference state to stored state with references', () => { + describe('toStoredSearchEmbeddableByRef', () => { + it('converts panel API by-reference state to stored SearchEmbeddable state with references', () => { const apiState: DiscoverSessionEmbeddableByReferenceState = { title: 'My Search', description: 'My description', @@ -444,7 +432,7 @@ describe('search embeddable transform utils', () => { selected_tab_id: 'tab-1', overrides: {}, }; - const result = byReferenceDiscoverSessionToSavedSearchEmbeddableState(apiState); + const result = toStoredSearchEmbeddableByRef(apiState); expect(result.references).toEqual([ { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, @@ -461,8 +449,8 @@ describe('search embeddable transform utils', () => { }); }); - describe('byValueDiscoverSessionToSavedSearchEmbeddableState', () => { - it('converts discover session by-value state to stored state with references', () => { + describe('toStoredSearchEmbeddableByValue', () => { + it('converts panel API by-value state to stored SearchEmbeddable state with references', () => { const apiState: DiscoverSessionEmbeddableByValueState = { title: 'Panel Title', description: 'Panel description', @@ -484,7 +472,7 @@ describe('search embeddable transform utils', () => { }, ], }; - const result = byValueDiscoverSessionToSavedSearchEmbeddableState(apiState); + const result = toStoredSearchEmbeddableByValue(apiState); expect(result.references).toEqual([ { id: 'data-view-1', @@ -543,7 +531,7 @@ describe('search embeddable transform utils', () => { }, ], }; - const result = byValueDiscoverSessionToSavedSearchEmbeddableState(apiState); + const result = toStoredSearchEmbeddableByValue(apiState); const searchSource = JSON.parse( result.state.attributes.tabs[0].attributes.kibanaSavedObjectMeta.searchSourceJSON ); @@ -616,9 +604,11 @@ describe('search embeddable transform utils', () => { }, ]; - const apiState = byValueSavedSearchToDiscoverSessionEmbeddableState(storedState, references); - const { state: reverted, references: revertedRefs } = - byValueDiscoverSessionToSavedSearchEmbeddableState(apiState, []); + 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); @@ -655,12 +645,11 @@ describe('search embeddable transform utils', () => { { name: SAVED_SEARCH_SAVED_OBJECT_REF_NAME, type: SavedSearchType, id: 'session-ref-1' }, ]; - const apiState = byReferenceSavedSearchToDiscoverSessionEmbeddableState( - storedState, - references + const apiState = fromStoredSearchEmbeddableByRef(storedState, references); + const { state: reverted, references: revertedRefs } = toStoredSearchEmbeddableByRef( + apiState, + [] ); - const { state: reverted, references: revertedRefs } = - byReferenceDiscoverSessionToSavedSearchEmbeddableState(apiState, []); expect(reverted.title).toBe(storedState.title); expect(reverted.description).toBe(storedState.description); @@ -717,7 +706,7 @@ describe('search embeddable transform utils', () => { }); }); - describe('fromStoredSearchEmbeddableState', () => { + describe('fromStoredPanelOverrides', () => { it('converts stored state with all fields to panel overrides', () => { const storedState: StoredSearchEmbeddableState = { sort: [['@timestamp', 'desc']], @@ -734,7 +723,7 @@ describe('search embeddable transform utils', () => { }, }, }; - const result = fromStoredSearchEmbeddableState(storedState); + const result = toDiscoverSessionPanelOverrides(storedState); expect(result).toEqual({ sort: [{ name: '@timestamp', direction: 'desc' }], column_order: ['message', '@timestamp'], @@ -756,7 +745,7 @@ describe('search embeddable transform utils', () => { columns: ['message'], grid: { columns: {} }, }; - const result = fromStoredSearchEmbeddableState(storedState); + const result = toDiscoverSessionPanelOverrides(storedState); expect(result).toEqual({ sort: [{ name: '@timestamp', direction: 'desc' }], column_order: ['message'], @@ -773,7 +762,7 @@ describe('search embeddable transform utils', () => { rowHeight: 5, headerRowHeight: 2, }; - const result = fromStoredSearchEmbeddableState(storedState); + const result = toDiscoverSessionPanelOverrides(storedState); expect(result.row_height).toBe(5); expect(result.header_row_height).toBe(2); }); @@ -783,13 +772,13 @@ describe('search embeddable transform utils', () => { rowHeight: -1, headerRowHeight: -1, }; - const result = fromStoredSearchEmbeddableState(storedState); + const result = toDiscoverSessionPanelOverrides(storedState); expect(result.row_height).toBe('auto'); expect(result.header_row_height).toBe('auto'); }); }); - describe('toStoredSearchEmbeddableState', () => { + describe('toStoredPanelOverrides', () => { it('converts panel overrides with all fields to stored state', () => { const apiState = { sort: [{ name: '@timestamp', direction: 'desc' as const }], @@ -801,7 +790,7 @@ describe('search embeddable transform utils', () => { header_row_height: 3, density: DataGridDensity.COMPACT, }; - const result = toStoredSearchEmbeddableState(apiState); + const result = fromDiscoverSessionPanelOverrides(apiState); expect(result).toEqual({ sort: [['@timestamp', 'desc']], columns: ['message', '@timestamp'], @@ -823,7 +812,7 @@ describe('search embeddable transform utils', () => { sort: [{ name: '@timestamp', direction: 'desc' as const }], column_order: ['message'], }; - const result = toStoredSearchEmbeddableState(apiState); + const result = fromDiscoverSessionPanelOverrides(apiState); expect(result).toEqual({ sort: [['@timestamp', 'desc']], columns: ['message'], @@ -840,7 +829,7 @@ describe('search embeddable transform utils', () => { row_height: 'auto' as const, header_row_height: 'auto' as const, }; - const result = toStoredSearchEmbeddableState(apiState); + const result = fromDiscoverSessionPanelOverrides(apiState); expect(result.rowHeight).toBe(-1); expect(result.headerRowHeight).toBe(-1); }); @@ -850,12 +839,12 @@ describe('search embeddable transform utils', () => { row_height: 5, header_row_height: 2, }; - const result = toStoredSearchEmbeddableState(apiState); + const result = fromDiscoverSessionPanelOverrides(apiState); expect(result.rowHeight).toBe(5); expect(result.headerRowHeight).toBe(2); }); - it('round-trips with fromStoredSearchEmbeddableState', () => { + it('round-trips with fromStoredPanelOverrides', () => { const storedState: StoredSearchEmbeddableState = { sort: [ ['@timestamp', 'desc'], @@ -873,8 +862,8 @@ describe('search embeddable transform utils', () => { }, }, }; - const overrides = fromStoredSearchEmbeddableState(storedState); - const back = toStoredSearchEmbeddableState(overrides); + 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); diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts index d8a274ad7d7c0..d8761b858cabd 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -22,8 +22,8 @@ import type { SavedObjectReference } from '@kbn/core/server'; import { DataGridDensity } from '@kbn/discover-utils'; import { isOfAggregateQueryType } from '@kbn/es-query'; import { - isByReferenceDiscoverSessionEmbeddableState, - isByValueSavedSearchEmbeddableState, + isDiscoverSessionEmbeddableByReferenceState, + isSearchEmbeddableByValueState, } from './type_guards'; import type { DiscoverSessionClassicTab, @@ -42,28 +42,28 @@ import type { } from './types'; import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; -export function savedSearchToDiscoverSessionEmbeddableState( +export function fromStoredSearchEmbeddable( storedState: SearchEmbeddableState | StoredSearchEmbeddableState, references: SavedObjectReference[] = [] ): DiscoverSessionEmbeddableState { - return isByValueSavedSearchEmbeddableState(storedState) - ? byValueSavedSearchToDiscoverSessionEmbeddableState(storedState, [ + return isSearchEmbeddableByValueState(storedState) + ? fromStoredSearchEmbeddableByValue(storedState, [ ...references, ...(storedState.attributes.references ?? []), ]) - : byReferenceSavedSearchToDiscoverSessionEmbeddableState(storedState, references); + : fromStoredSearchEmbeddableByRef(storedState, references); } -export function discoverSessionToSavedSearchEmbeddableState( +export function toStoredSearchEmbeddable( apiState: DiscoverSessionEmbeddableState, references: SavedObjectReference[] = [] ): { state: StoredSearchEmbeddableState; references: SavedObjectReference[] } { - return isByReferenceDiscoverSessionEmbeddableState(apiState) - ? byReferenceDiscoverSessionToSavedSearchEmbeddableState(apiState, references) - : byValueDiscoverSessionToSavedSearchEmbeddableState(apiState, references); + return isDiscoverSessionEmbeddableByReferenceState(apiState) + ? toStoredSearchEmbeddableByRef(apiState, references) + : toStoredSearchEmbeddableByValue(apiState, references); } -export function byReferenceSavedSearchToDiscoverSessionEmbeddableState( +export function fromStoredSearchEmbeddableByRef( storedState: SearchEmbeddableByReferenceState | StoredSearchEmbeddableByReferenceState, references: SavedObjectReference[] = [] ): DiscoverSessionEmbeddableByReferenceState { @@ -90,11 +90,11 @@ export function byReferenceSavedSearchToDiscoverSessionEmbeddableState( ...otherAttrs, discover_session_id: savedObjectId, selected_tab_id: selectedTabId, - overrides: fromStoredSearchEmbeddableState(storedState), + overrides: toDiscoverSessionPanelOverrides(storedState), }; } -export function byReferenceDiscoverSessionToSavedSearchEmbeddableState( +export function toStoredSearchEmbeddableByRef( apiState: DiscoverSessionEmbeddableByReferenceState, references: SavedObjectReference[] = [] ): { state: StoredSearchEmbeddableByReferenceState; references: SavedObjectReference[] } { @@ -106,7 +106,7 @@ export function byReferenceDiscoverSessionToSavedSearchEmbeddableState( const { discover_session_id, selected_tab_id, overrides, ...otherAttrs } = apiState; const state: StoredSearchEmbeddableByReferenceState = { ...otherAttrs, - ...toStoredSearchEmbeddableState(overrides ?? {}), + ...fromDiscoverSessionPanelOverrides(overrides ?? {}), ...(selected_tab_id != null && { selectedTabId: selected_tab_id }), }; return { @@ -115,7 +115,7 @@ export function byReferenceDiscoverSessionToSavedSearchEmbeddableState( }; } -export function byValueSavedSearchToDiscoverSessionEmbeddableState( +export function fromStoredSearchEmbeddableByValue( storedState: StoredSearchEmbeddableByValueState, references: SavedObjectReference[] = [] ): DiscoverSessionEmbeddableByValueState { @@ -133,14 +133,14 @@ export function byValueSavedSearchToDiscoverSessionEmbeddableState( } = storedState; const [tab] = attributes.tabs ?? extractTabs(attributes).tabs; const apiTab = fromStoredTab(tab.attributes, references); - const panelOverrides = fromStoredSearchEmbeddableState(storedState); + const panelOverrides = toDiscoverSessionPanelOverrides(storedState); return { ...otherAttrs, tabs: [{ ...apiTab, ...panelOverrides }], }; } -export function byValueDiscoverSessionToSavedSearchEmbeddableState( +export function toStoredSearchEmbeddableByValue( apiState: DiscoverSessionEmbeddableByValueState, references: SavedObjectReference[] = [] ): { state: StoredSearchEmbeddableByValueState; references: SavedObjectReference[] } { @@ -151,7 +151,7 @@ export function byValueDiscoverSessionToSavedSearchEmbeddableState( const { state: tabAttributes, references: tabReferences } = toStoredTab(apiTab); const state: StoredSearchEmbeddableByValueState = { ...otherAttrs, - ...toStoredSearchEmbeddableState(apiTab), + ...fromDiscoverSessionPanelOverrides(apiTab), attributes: { ...tabAttributes, sort: tabAttributes.sort as SavedSearchAttributes['sort'], @@ -186,7 +186,7 @@ export function fromStoredTab( kibanaSavedObjectMeta: { searchSourceJSON }, } = tab; const apiTab = { - ...fromStoredSearchEmbeddableState(tab), + ...toDiscoverSessionPanelOverrides(tab), sort: fromStoredSort(sort), header_row_height: fromStoredHeight(headerRowHeight)!, density: density ?? DataGridDensity.COMPACT, @@ -220,7 +220,7 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { }; const [searchSourceFields, references] = extractReferences(searchSourceValues); const state: DiscoverSessionTabAttributes = { - ...toStoredSearchEmbeddableState(apiTab), + ...fromDiscoverSessionPanelOverrides(apiTab), sort: toStoredSort(sort), columns: columnOrder ?? [], grid: toStoredGrid(columnSettings), @@ -232,7 +232,7 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { return { state, references }; } -export function fromStoredSearchEmbeddableState( +export function toDiscoverSessionPanelOverrides( storedState: StoredSearchEmbeddableState | DiscoverSessionTabAttributes ): DiscoverSessionPanelOverrides { const { sort, columns, rowHeight, sampleSize, rowsPerPage, headerRowHeight, density, grid } = @@ -252,7 +252,7 @@ export function fromStoredSearchEmbeddableState( }; } -export function toStoredSearchEmbeddableState( +export function fromDiscoverSessionPanelOverrides( apiState: DiscoverSessionPanelOverrides ): StoredSearchEmbeddableState { const { @@ -286,7 +286,7 @@ export function fromStoredGrid( export function toStoredGrid( columnSettings: DiscoverSessionTab['column_settings'] = {} ): DiscoverSessionTabAttributes['grid'] { - return Object.keys(columnSettings ?? {}).length > 0 ? { columns: columnSettings } : {}; + return Object.keys(columnSettings).length > 0 ? { columns: columnSettings } : {}; } export function fromStoredSort( diff --git a/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts b/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts index 14cc17e51d027..f3cf99ed1ad32 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/type_guards.ts @@ -19,13 +19,13 @@ import type { StoredSearchEmbeddableState, } from './types'; -export function isByReferenceDiscoverSessionEmbeddableState( +export function isDiscoverSessionEmbeddableByReferenceState( state: DiscoverSessionEmbeddableState ): state is DiscoverSessionEmbeddableByReferenceState { return 'discover_session_id' in state; } -export function isByValueSavedSearchEmbeddableState( +export function isSearchEmbeddableByValueState( state: SearchEmbeddableState | StoredSearchEmbeddableState ): state is SearchEmbeddableByValueState | StoredSearchEmbeddableByValueState { return 'attributes' in state && typeof state.attributes === 'object' && state.attributes !== null; @@ -34,5 +34,5 @@ export function isByValueSavedSearchEmbeddableState( export function isSearchEmbeddableLegacyPanelState( state: SearchEmbeddablePanelApiState ): state is SearchEmbeddableState { - return 'savedObjectId' in state || isByValueSavedSearchEmbeddableState(state); + return 'savedObjectId' in state || isSearchEmbeddableByValueState(state); } diff --git a/src/platform/plugins/shared/discover/public/__mocks__/services.ts b/src/platform/plugins/shared/discover/public/__mocks__/services.ts index eb69521456ec5..75780617e17e3 100644 --- a/src/platform/plugins/shared/discover/public/__mocks__/services.ts +++ b/src/platform/plugins/shared/discover/public/__mocks__/services.ts @@ -330,7 +330,7 @@ export function createDiscoverServicesMock(): DiscoverServices { discoverFeatureFlags: { getCascadeLayoutEnabled: jest.fn(() => false), getIsEsqlDefault: jest.fn(() => false), - getEmbeddableTransformsEnabled: jest.fn(() => true), + getEmbeddableTransformsEnabled: jest.fn(() => false), }, embeddableEditor: { isByValueEditor: jest.fn(() => false), 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 a134c5e611b80..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 @@ -19,13 +19,13 @@ import type { StoredSearchEmbeddableByValueState, } from '../../../common/embeddable/types'; import { - byReferenceSavedSearchToDiscoverSessionEmbeddableState, - byValueDiscoverSessionToSavedSearchEmbeddableState, - byValueSavedSearchToDiscoverSessionEmbeddableState, - isByReferenceDiscoverSessionEmbeddableState, + fromStoredSearchEmbeddable, + fromStoredSearchEmbeddableByRef, + fromStoredSearchEmbeddableByValue, + isDiscoverSessionEmbeddableByReferenceState, isSearchEmbeddableLegacyPanelState, - savedSearchToDiscoverSessionEmbeddableState, - toStoredSearchEmbeddableState, + toStoredSearchEmbeddableByValue, + fromDiscoverSessionPanelOverrides, } from '../../../common/embeddable'; import { EDITABLE_SAVED_SEARCH_KEYS } from '../../../common/embeddable/constants'; import type { DiscoverServices } from '../../build_services'; @@ -42,10 +42,10 @@ export const deserializeState = async ({ }): Promise => { const panelState = pick(serializedState, EDITABLE_PANEL_KEYS); const apiState = isSearchEmbeddableLegacyPanelState(serializedState) - ? savedSearchToDiscoverSessionEmbeddableState(serializedState) + ? fromStoredSearchEmbeddable(serializedState) : serializedState; - if (isByReferenceDiscoverSessionEmbeddableState(apiState)) { + if (isDiscoverSessionEmbeddableByReferenceState(apiState)) { // by reference const { discover_session_id: savedObjectId, selected_tab_id: selectedTabId } = apiState; const { getDiscoverSession } = discoverServices.savedSearch; @@ -56,7 +56,7 @@ export const deserializeState = async ({ const resolvedTab = selectedTab ?? session.tabs[0]; const isSelectedTabDeleted = Boolean(selectedTabId && !selectedTab); const resolvedSelectedTabId = isSelectedTabDeleted ? selectedTabId : resolvedTab?.id; - const savedObjectOverride = toStoredSearchEmbeddableState(apiState.overrides ?? {}); + 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 @@ -78,11 +78,10 @@ export const deserializeState = async ({ } else { // by value const [tab] = apiState.tabs; - const savedObjectOverride = toStoredSearchEmbeddableState(tab ?? {}); + const savedObjectOverride = fromDiscoverSessionPanelOverrides(tab ?? {}); const { byValueToSavedSearch } = discoverServices.savedSearch; - const { state: storedState, references } = - byValueDiscoverSessionToSavedSearchEmbeddableState(apiState); + const { state: storedState, references } = toStoredSearchEmbeddableByValue(apiState); const savedSearch = await byValueToSavedSearch( { attributes: { ...storedState.attributes, references } }, true @@ -156,9 +155,7 @@ export const serializeState = ({ ...(selectedTabId !== undefined && { selectedTabId }), savedObjectId, }; - return embeddableTransformsEnabled - ? byReferenceSavedSearchToDiscoverSessionEmbeddableState(stored) - : stored; + return embeddableTransformsEnabled ? fromStoredSearchEmbeddableByRef(stored) : stored; } const stored: StoredSearchEmbeddableByValueState = { @@ -167,7 +164,5 @@ export const serializeState = ({ ...serializeDynamicActions?.(), attributes: savedSearchAttributes, }; - return embeddableTransformsEnabled - ? byValueSavedSearchToDiscoverSessionEmbeddableState(stored, []) - : stored; + return embeddableTransformsEnabled ? fromStoredSearchEmbeddableByValue(stored, []) : stored; }; diff --git a/src/platform/plugins/shared/discover/public/plugin.tsx b/src/platform/plugins/shared/discover/public/plugin.tsx index 0d8a99269418b..62607159d3aa7 100644 --- a/src/platform/plugins/shared/discover/public/plugin.tsx +++ b/src/platform/plugins/shared/discover/public/plugin.tsx @@ -30,12 +30,7 @@ import { ADD_PANEL_TRIGGER, ON_OPEN_PANEL_MENU } from '@kbn/ui-actions-plugin/co 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, - EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, - PLUGIN_ID, - type DiscoverAppLocator, -} from '../common'; +import { DISCOVER_APP_LOCATOR, PLUGIN_ID, type DiscoverAppLocator } from '../common'; import { DISCOVER_CONTEXT_APP_LOCATOR, type DiscoverContextAppLocator, @@ -477,18 +472,14 @@ export class DiscoverPlugin }); }); - let embeddableTransformsEnabled = false; - core.getStartServices().then(([{ featureFlags }]) => { - embeddableTransformsEnabled = featureFlags.getBooleanValue( - EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, - false - ); - }); plugins.embeddable.registerLegacyURLTransform( SEARCH_EMBEDDABLE_TYPE, async (transformDrilldownsOut: DrilldownTransforms['transformOut']) => { + const discoverServices = await getDiscoverServicesForEmbeddable(); const { getTransformOut } = await getEmbeddableServices(); - return getTransformOut(transformDrilldownsOut, () => embeddableTransformsEnabled); + return getTransformOut(transformDrilldownsOut, () => + discoverServices.discoverFeatureFlags.getEmbeddableTransformsEnabled() + ); } ); } diff --git a/src/platform/plugins/shared/discover/server/plugin.ts b/src/platform/plugins/shared/discover/server/plugin.ts index e42fc84235f1c..5d915bc5a265f 100644 --- a/src/platform/plugins/shared/discover/server/plugin.ts +++ b/src/platform/plugins/shared/discover/server/plugin.ts @@ -7,7 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { CoreSetup, CoreStart, Logger, Plugin } from '@kbn/core/server'; +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'; import type { HomeServerPluginSetup } from '@kbn/home-plugin/server'; @@ -36,11 +37,11 @@ export class DiscoverServerPlugin implements Plugin { private readonly config: ConfigSchema; - private readonly logger: Logger; + private subscriptions: Subscription[] = []; + private embeddableTransformsEnabled = false; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); - this.logger = initializerContext.logger.get(); } public setup( @@ -68,25 +69,12 @@ export class DiscoverServerPlugin }); } - let embeddableTransformsEnabled = false; - core - .getStartServices() - .then(([{ featureFlags }]) => { - featureFlags - .getBooleanValue$(EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, embeddableTransformsEnabled) - .subscribe((value) => { - embeddableTransformsEnabled = value; - }); - }) - .catch((error) => { - this.logger.error(error); - }); plugins.embeddable.registerEmbeddableFactory(createSearchEmbeddableFactory()); plugins.embeddable.registerTransforms(SEARCH_EMBEDDABLE_TYPE, { getTransforms: (drilldownTransforms) => - getSearchEmbeddableTransforms(drilldownTransforms, () => embeddableTransformsEnabled), + getSearchEmbeddableTransforms(drilldownTransforms, () => this.embeddableTransformsEnabled), getSchema: (getDrilldownsSchema) => - embeddableTransformsEnabled + this.embeddableTransformsEnabled ? getDiscoverSessionEmbeddableSchema(getDrilldownsSchema) : undefined, }); @@ -111,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()); + } } From b63f968984c81587665904d994bee2f77a255caf Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Tue, 31 Mar 2026 22:49:02 -0700 Subject: [PATCH 29/33] Review feedback part 2 --- .../src/from_stored_data_view.ts | 5 +- .../discover/common/embeddable/constants.ts | 10 ++ .../discover/common/embeddable/index.ts | 4 + .../common/embeddable/transform_utils.test.ts | 14 ++- .../common/embeddable/transform_utils.ts | 33 +++-- ...et_legacy_log_stream_embeddable_factory.ts | 55 ++++++--- .../get_search_embeddable_factory.test.tsx | 20 ++-- .../discover/public/embeddable/types.ts | 4 +- .../utils/serialization_utils.test.ts | 7 +- .../discover/server/embeddable/schema.ts | 113 ++++++++---------- 10 files changed, 145 insertions(+), 120 deletions(-) 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 index 99bcddf0affb3..3cdff6ea50cfd 100644 --- 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 @@ -29,11 +29,10 @@ export function fromStoredDataView( if (typeof index === 'string') { return { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: index }; } - const title = index.title ?? index.id; - if (!title) throw new Error('Cannot derive data view without `title` or `id`'); + if (!index.title) throw new Error('Cannot derive data view without `title` or `id`'); return { type: AS_CODE_DATA_VIEW_SPEC_TYPE, - index_pattern: title, + index_pattern: index.title, time_field: index.timeFieldName, runtime_fields: fromStoredRuntimeFields( index.runtimeFieldMap, diff --git a/src/platform/plugins/shared/discover/common/embeddable/constants.ts b/src/platform/plugins/shared/discover/common/embeddable/constants.ts index 94f2cd2a10b2a..e19910e059180 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/constants.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/constants.ts @@ -12,6 +12,16 @@ 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/index.ts b/src/platform/plugins/shared/discover/common/embeddable/index.ts index 555be16a6f79b..76c672a134f53 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/index.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/index.ts @@ -7,6 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +export { + DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_ID, + DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_LABEL, +} from './constants'; export { getSearchEmbeddableTransforms, type SearchEmbeddablePanelApiState, 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 index df1432e6124e6..4c6559da53af0 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -36,7 +36,11 @@ import type { StoredSearchEmbeddableByValueState, StoredSearchEmbeddableState, } from './types'; -import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; +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, @@ -483,6 +487,10 @@ describe('search embeddable transform utils', () => { 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({ @@ -613,6 +621,10 @@ describe('search embeddable transform utils', () => { 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; diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts index d8761b858cabd..c0a42938869f7 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -26,7 +26,6 @@ import { isSearchEmbeddableByValueState, } from './type_guards'; import type { - DiscoverSessionClassicTab, DiscoverSessionEmbeddableByReferenceState, DiscoverSessionEmbeddableByValueState, DiscoverSessionEmbeddableState, @@ -40,7 +39,11 @@ import type { StoredSearchEmbeddableByValueState, StoredSearchEmbeddableState, } from './types'; -import { SAVED_SEARCH_SAVED_OBJECT_REF_NAME } from './constants'; +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, @@ -155,12 +158,12 @@ export function toStoredSearchEmbeddableByValue( attributes: { ...tabAttributes, sort: tabAttributes.sort as SavedSearchAttributes['sort'], - title: apiState.title ?? '', // Only necessary for schema validation - description: apiState.description ?? '', // Only necessary for schema validation + title: apiState.title ?? '', + description: apiState.description ?? '', tabs: [ { - id: '', // Only necessary for schema validation - label: '', // Only necessary for schema validation + id: DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_ID, + label: DISCOVER_SESSION_EMBEDDABLE_SYNTHETIC_TAB_LABEL, attributes: tabAttributes, }, ], @@ -188,7 +191,7 @@ export function fromStoredTab( const apiTab = { ...toDiscoverSessionPanelOverrides(tab), sort: fromStoredSort(sort), - header_row_height: fromStoredHeight(headerRowHeight)!, + header_row_height: fromStoredHeight(headerRowHeight), density: density ?? DataGridDensity.COMPACT, }; const searchSourceValues = parseSearchSourceJSON(searchSourceJSON); @@ -198,9 +201,7 @@ export function fromStoredTab( : { ...apiTab, ...(sampleSize && { sample_size: sampleSize }), - ...(rowsPerPage && { - rows_per_page: rowsPerPage as DiscoverSessionClassicTab['rows_per_page'], - }), + ...(rowsPerPage && { rows_per_page: rowsPerPage }), query, filters: fromStoredFilters(filter) ?? [], data_source: fromStoredDataView(index), @@ -225,7 +226,7 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { columns: columnOrder ?? [], grid: toStoredGrid(columnSettings), hideChart: false, - isTextBasedQuery: !('data_source' in apiTab), + isTextBasedQuery: isOfAggregateQueryType(apiTab.query), kibanaSavedObjectMeta: { searchSourceJSON: JSON.stringify(searchSourceFields) }, ...('view_mode' in apiTab && { viewMode: apiTab.view_mode }), }; @@ -244,9 +245,7 @@ export function toDiscoverSessionPanelOverrides( Object.keys(grid?.columns ?? {}).length && { column_settings: fromStoredGrid(grid) }), ...(rowHeight && { row_height: fromStoredHeight(rowHeight) }), ...(sampleSize && { sample_size: sampleSize }), - ...(rowsPerPage && { - rows_per_page: rowsPerPage as DiscoverSessionPanelOverrides['rows_per_page'], - }), + ...(rowsPerPage && { rows_per_page: rowsPerPage }), ...(headerRowHeight && { header_row_height: fromStoredHeight(headerRowHeight) }), ...(density && { density }), }; @@ -305,10 +304,8 @@ export function toStoredSort( return sort.map((s) => [s.name, s.direction]); } -export function fromStoredHeight< - T extends DiscoverSessionTab['row_height'] | DiscoverSessionTab['header_row_height'] ->(height: number = 3): T { - return (height === -1 ? 'auto' : height) as T; +export function fromStoredHeight(height: number = 3): DiscoverSessionTab['row_height'] { + return height === -1 ? 'auto' : height; } export function toStoredHeight( 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 e5d6abbd321c9..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 @@ -7,8 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { AS_CODE_DATA_VIEW_SPEC_TYPE } from '@kbn/as-code-data-views-schema'; -import type { DiscoverSessionEmbeddableState } from '../../server'; +import type { SavedSearch } from '@kbn/saved-search-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { getAllLogsDataViewSpec } from '@kbn/discover-utils/src'; +import { toSavedSearchAttributes } from '@kbn/saved-search-plugin/common'; import { getSearchEmbeddableFactory } from './get_search_embeddable_factory'; import { LEGACY_LOG_STREAM_EMBEDDABLE } from './constants'; @@ -18,21 +20,40 @@ export const getLegacyLogStreamEmbeddableFactory = ( const searchEmbeddableFactory = getSearchEmbeddableFactory({ startServices, discoverServices }); const logStreamEmbeddableFactory: ReturnType = { type: LEGACY_LOG_STREAM_EMBEDDABLE, - buildEmbeddable: async ({ initialState: logsInitialState, ...restParams }) => { - const initialState = { - ...logsInitialState, - tabs: [ - { - data_source: { - type: AS_CODE_DATA_VIEW_SPEC_TYPE, - index_pattern: discoverServices.logsDataAccess - ? await discoverServices.logsDataAccess.services.logSourcesService.getFlattenedLogSources() - : 'logs-*-*', - time_field: '@timestamp', - }, - }, - ], - } as DiscoverSessionEmbeddableState; + buildEmbeddable: async ({ initialState, ...restParams }) => { + const searchSource = await discoverServices.data.search.searchSource.create(); + let fallbackPattern = 'logs-*-*'; + // Given that the logDataAccess service is an optional dependency with discover, we need to check if it exists + if (discoverServices.logsDataAccess) { + fallbackPattern = + await discoverServices.logsDataAccess.services.logSourcesService.getFlattenedLogSources(); + } + + const spec = getAllLogsDataViewSpec({ allLogsIndexPattern: fallbackPattern }); + const dataView: DataView = await discoverServices.data.dataViews.create(spec); + + // Finally assign the data view to the search source + searchSource.setField('index', dataView); + + const savedSearch: SavedSearch = { + title: initialState.title, + description: initialState.description, + timeRange: initialState.time_range, + sort: 'sort' in initialState ? initialState.sort : [], + columns: 'columns' in initialState ? initialState.columns : [], + searchSource, + managed: false, + }; + const { searchSourceJSON, references } = searchSource.serialize(); + + initialState = { + ...initialState, + attributes: { + ...toSavedSearchAttributes(savedSearch, searchSourceJSON), + references, + }, + }; + return searchEmbeddableFactory.buildEmbeddable({ initialState, ...restParams }); }, }; 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 31df7641cdda5..6c93b70552149 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 @@ -25,11 +25,7 @@ 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, - SearchEmbeddablePanelApiState, - SearchEmbeddableRuntimeState, -} from './types'; +import type { SearchEmbeddableApi, SearchEmbeddableRuntimeState } from './types'; import { SolutionType } from '../context_awareness'; import { mockInitializeDrilldownsManager } from '@kbn/embeddable-plugin/public/mocks'; import { renderWithI18n } from '@kbn/test-jest-helpers'; @@ -167,7 +163,7 @@ describe('saved search embeddable', () => { runtimeState = getInitialRuntimeState({ searchMock: search }); const { Component, api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -202,7 +198,7 @@ describe('saved search embeddable', () => { const { Component, api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -284,7 +280,7 @@ describe('saved search embeddable', () => { }); const { api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -442,7 +438,7 @@ describe('saved search embeddable', () => { runtimeState = getInitialRuntimeState(); await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -467,7 +463,7 @@ describe('saved search embeddable', () => { }; await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -494,7 +490,7 @@ describe('saved search embeddable', () => { runtimeState = getInitialRuntimeState(); const { api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // runtimeState passed via mocked deserializeState + initialState: { discover_session_id: 'id', overrides: {} }, finalizeApi: finalizeApiMock, uuid, parentApi: mockedDashboardApi, @@ -527,7 +523,7 @@ describe('saved search embeddable', () => { }); const { Component, api } = await factory.buildEmbeddable({ initializeDrilldownsManager: mockInitializeDrilldownsManager, - initialState: { discover_session_id: 'id' } as SearchEmbeddablePanelApiState, // 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/types.ts b/src/platform/plugins/shared/discover/public/embeddable/types.ts index 58039dc1bddd5..37953e3b9b7db 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/types.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/types.ts @@ -44,9 +44,7 @@ import type { export type { SearchEmbeddablePanelApiState }; /** - * Input state accepted by the search embeddable factory. Extends the persisted - * session state with optional display options passed by solutions (e.g. APM, Infra) - * when using SavedSearchComponent outside of dashboards. These options are not persisted. + * Input state accepted by the search embeddable factory. */ export type SearchEmbeddableInputState = SearchEmbeddablePanelApiState & { nonPersistedDisplayOptions?: NonPersistedDisplayOptions; 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 c4500ecc6ac8b..0cb459bc59c61 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 @@ -8,7 +8,6 @@ */ import { AS_CODE_DATA_VIEW_REFERENCE_TYPE } from '@kbn/as-code-data-views-schema'; -import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { discoverServiceMock } from '../../__mocks__/services'; @@ -41,7 +40,7 @@ describe('Serialization utils', () => { getIsEsqlDefault: jest.fn(() => false), getEmbeddableTransformsEnabled: jest.fn(() => false), }, - } as unknown as DiscoverServices; + } satisfies DiscoverServices; const mockedSavedSearchAttributes: SearchEmbeddableByValueState['attributes'] = { kibanaSavedObjectMeta: { @@ -326,9 +325,9 @@ describe('Serialization utils', () => { initialState: { ...mockedSavedSearchAttributes, tabs: [], - serializedSearchSource: {} as SerializedSearchSourceFields, + serializedSearchSource: {}, }, - savedSearch: savedSearch as Parameters[0]['savedSearch'], + savedSearch, serializeTitles: jest.fn().mockReturnValue({ title: 'test1', description: 'description' }), serializeTimeRange: jest.fn(), serializeDynamicActions: jest.fn(), diff --git a/src/platform/plugins/shared/discover/server/embeddable/schema.ts b/src/platform/plugins/shared/discover/server/embeddable/schema.ts index bae1800a199d6..0ef7e3f8856df 100644 --- a/src/platform/plugins/shared/discover/server/embeddable/schema.ts +++ b/src/platform/plugins/shared/discover/server/embeddable/schema.ts @@ -66,23 +66,15 @@ export const viewModeSchema = schema.oneOf( 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".', - }, - } - ) + schema.number({ + min: 1, + max: 10000, + 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({ @@ -133,34 +125,39 @@ const dataTableSchema = schema.object( description: 'Sort configuration for the data table (field and direction).', }, }), - density: 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.', - }, - } + 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.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.', - }, - } + 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( @@ -274,23 +271,15 @@ const panelOverridesSchema = 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: - '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".', - }, - } - ) + 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({ @@ -367,7 +356,7 @@ const getDiscoverSessionByValueEmbeddableSchema = withPanelSchemas( maxSize: 1, meta: { description: - 'Inline tab configuration. Used when no `discover_session_id` is set. Panel-level fields (e.g. `column_order`, `sort`) override these when provided. Currently supports one tab.', + 'Inline tab configuration. Used when no `discover_session_id` is set. Currently supports one tab.', }, }), }), From de337c170fd1954900c7f711243666ab57a0811e Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:08:24 +0000 Subject: [PATCH 30/33] Changes from node scripts/eslint_all_files --no-cache --fix --- .../common/embeddable/search_embeddable_transforms.test.ts | 1 - 1 file changed, 1 deletion(-) 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 8525d2f2c4ac1..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 @@ -244,7 +244,6 @@ describe('searchEmbeddableTransforms', () => { rows_per_page: 100, sample_size: 1000, data_source: { type: AS_CODE_DATA_VIEW_REFERENCE_TYPE, id: 'data-view-1' }, - }, ], }; From d379f27f253721f6fcef12239b3955e07d08a4f0 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Wed, 1 Apr 2026 12:44:57 -0700 Subject: [PATCH 31/33] Lower bundle size --- src/platform/plugins/shared/discover/public/plugin.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/platform/plugins/shared/discover/public/plugin.tsx b/src/platform/plugins/shared/discover/public/plugin.tsx index 62607159d3aa7..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, @@ -488,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(); From f17d1df16daae50e2467b2b0a4cd1105cc908858 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Wed, 1 Apr 2026 13:52:12 -0700 Subject: [PATCH 32/33] Fix types --- .../shared/discover/common/embeddable/transform_utils.test.ts | 4 ++++ .../shared/discover/common/embeddable/transform_utils.ts | 1 + 2 files changed, 5 insertions(+) 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 index 4c6559da53af0..866d5dc4c52c7 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.test.ts @@ -245,6 +245,7 @@ describe('search embeddable transform utils', () => { description: '', grid: {}, hideChart: false, + hideTable: false, viewMode: VIEW_MODE.DOCUMENT_LEVEL, isTextBasedQuery: false, timeRestore: false, @@ -261,6 +262,7 @@ describe('search embeddable transform utils', () => { columns: ['message'], grid: {}, hideChart: false, + hideTable: false, viewMode: VIEW_MODE.DOCUMENT_LEVEL, isTextBasedQuery: false, timeRestore: false, @@ -574,6 +576,7 @@ describe('search embeddable transform utils', () => { columns: ['message', '@timestamp'], grid: { columns: { '@timestamp': { width: 200 } } }, hideChart: false, + hideTable: false, viewMode: VIEW_MODE.DOCUMENT_LEVEL, isTextBasedQuery: false, timeRestore: false, @@ -590,6 +593,7 @@ describe('search embeddable transform utils', () => { columns: ['message', '@timestamp'], grid: { columns: { '@timestamp': { width: 200 } } }, hideChart: false, + hideTable: false, viewMode: VIEW_MODE.DOCUMENT_LEVEL, isTextBasedQuery: false, timeRestore: false, diff --git a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts index c0a42938869f7..5cc77cd4c0475 100644 --- a/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts +++ b/src/platform/plugins/shared/discover/common/embeddable/transform_utils.ts @@ -226,6 +226,7 @@ export function toStoredTab(apiTab: DiscoverSessionTab): { 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 }), From cfeaa921ab5d7f1f2b5689e461d832c730660a6a Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Wed, 1 Apr 2026 15:58:46 -0700 Subject: [PATCH 33/33] Fix type check --- .../embeddable/get_search_embeddable_factory.test.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 28a2a58726a13..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,