diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 56a56bdc2d59c..c572b59899fce 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { FilterMeta, Filter } from 'src/plugins/data/common'; + export interface ExistingFields { indexPatternTitle: string; existingFieldNames: string[]; @@ -13,3 +15,11 @@ export interface DateRange { fromDate: string; toDate: string; } + +export interface PersistableFilterMeta extends FilterMeta { + indexRefName?: string; +} + +export interface PersistableFilter extends Filter { + meta: PersistableFilterMeta; +} diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index f92343183a700..b1d1fbd40f485 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -33,7 +33,7 @@ import { navigationPluginMock } from '../../../../../src/plugins/navigation/publ import { TopNavMenuData } from '../../../../../src/plugins/navigation/public'; import { coreMock } from 'src/core/public/mocks'; -jest.mock('../persistence'); +jest.mock('../editor_frame_service/editor_frame/expression_helpers'); jest.mock('src/core/public'); jest.mock('../../../../../src/plugins/saved_objects/public', () => { // eslint-disable-next-line no-shadow @@ -282,11 +282,11 @@ describe('Lens App', () => { (defaultArgs.docStorage.load as jest.Mock).mockResolvedValue({ id: '1234', title: 'Daaaaaaadaumching!', - expression: 'valid expression', state: { query: 'fake query', - datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] }, + filters: [], }, + references: [], }); await act(async () => { instance.setProps({ docId: '1234' }); @@ -312,12 +312,11 @@ describe('Lens App', () => { args.editorFrame = frame; (args.docStorage.load as jest.Mock).mockResolvedValue({ id: '1234', - expression: 'valid expression', state: { query: 'fake query', filters: [{ query: { match_phrase: { src: 'test' } } }], - datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] }, }, + references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], }); instance = mount(); @@ -341,15 +340,13 @@ describe('Lens App', () => { expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ - doc: { + doc: expect.objectContaining({ id: '1234', - expression: 'valid expression', - state: { + state: expect.objectContaining({ query: 'fake query', filters: [{ query: { match_phrase: { src: 'test' } } }], - datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] }, - }, - }, + }), + }), }) ); }); @@ -410,7 +407,6 @@ describe('Lens App', () => { expression: 'valid expression', state: { query: 'kuery', - datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] }, }, } as jest.ResolvedValue); }); @@ -433,7 +429,12 @@ describe('Lens App', () => { } async function save({ - lastKnownDoc = { expression: 'kibana 3' }, + lastKnownDoc = { + references: [], + state: { + filters: [], + }, + }, initialDocId, ...saveProps }: SaveProps & { @@ -447,16 +448,14 @@ describe('Lens App', () => { args.editorFrame = frame; (args.docStorage.load as jest.Mock).mockResolvedValue({ id: '1234', - expression: 'kibana', + references: [], state: { query: 'fake query', - datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] }, filters: [], }, }); (args.docStorage.save as jest.Mock).mockImplementation(async ({ id }) => ({ id: id || 'aaa', - expression: 'kibana 2', })); await act(async () => { @@ -474,6 +473,7 @@ describe('Lens App', () => { onChange({ filterableIndexPatterns: [], doc: { id: initialDocId, ...lastKnownDoc } as Document, + isSaveable: true, }) ); @@ -507,7 +507,8 @@ describe('Lens App', () => { act(() => onChange({ filterableIndexPatterns: [], - doc: ({ id: 'will save this', expression: 'valid expression' } as unknown) as Document, + doc: ({ id: 'will save this' } as unknown) as Document, + isSaveable: true, }) ); instance.update(); @@ -526,7 +527,8 @@ describe('Lens App', () => { act(() => onChange({ filterableIndexPatterns: [], - doc: ({ id: 'will save this', expression: 'valid expression' } as unknown) as Document, + doc: ({ id: 'will save this' } as unknown) as Document, + isSaveable: true, }) ); instance.update(); @@ -541,11 +543,12 @@ describe('Lens App', () => { newTitle: 'hello there', }); - expect(args.docStorage.save).toHaveBeenCalledWith({ - id: undefined, - title: 'hello there', - expression: 'kibana 3', - }); + expect(args.docStorage.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: undefined, + title: 'hello there', + }) + ); expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true); @@ -561,11 +564,12 @@ describe('Lens App', () => { newTitle: 'hello there', }); - expect(args.docStorage.save).toHaveBeenCalledWith({ - id: undefined, - title: 'hello there', - expression: 'kibana 3', - }); + expect(args.docStorage.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: undefined, + title: 'hello there', + }) + ); expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true); @@ -581,11 +585,12 @@ describe('Lens App', () => { newTitle: 'hello there', }); - expect(args.docStorage.save).toHaveBeenCalledWith({ - id: '1234', - title: 'hello there', - expression: 'kibana 3', - }); + expect(args.docStorage.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: '1234', + title: 'hello there', + }) + ); expect(args.redirectTo).not.toHaveBeenCalled(); @@ -605,7 +610,8 @@ describe('Lens App', () => { act(() => onChange({ filterableIndexPatterns: [], - doc: ({ id: undefined, expression: 'new expression' } as unknown) as Document, + doc: ({ id: undefined } as unknown) as Document, + isSaveable: true, }) ); @@ -629,11 +635,12 @@ describe('Lens App', () => { newTitle: 'hello there', }); - expect(args.docStorage.save).toHaveBeenCalledWith({ - expression: 'kibana 3', - id: undefined, - title: 'hello there', - }); + expect(args.docStorage.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: undefined, + title: 'hello there', + }) + ); expect(args.redirectTo).toHaveBeenCalledWith('aaa', true, true); }); @@ -683,7 +690,8 @@ describe('Lens App', () => { await act(async () => onChange({ filterableIndexPatterns: [], - doc: ({ id: '123', expression: 'valid expression' } as unknown) as Document, + doc: ({ id: '123' } as unknown) as Document, + isSaveable: true, }) ); instance.update(); @@ -722,7 +730,8 @@ describe('Lens App', () => { await act(async () => onChange({ filterableIndexPatterns: [], - doc: ({ expression: 'valid expression' } as unknown) as Document, + doc: ({} as unknown) as Document, + isSaveable: true, }) ); instance.update(); @@ -745,7 +754,6 @@ describe('Lens App', () => { expression: 'valid expression', state: { query: 'kuery', - datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] }, }, } as jest.ResolvedValue); }); @@ -790,8 +798,9 @@ describe('Lens App', () => { await act(async () => { onChange({ - filterableIndexPatterns: [{ id: '1', title: 'newIndex' }], - doc: ({ id: undefined, expression: 'valid expression' } as unknown) as Document, + filterableIndexPatterns: ['1'], + doc: ({ id: undefined } as unknown) as Document, + isSaveable: true, }); }); @@ -808,8 +817,9 @@ describe('Lens App', () => { await act(async () => { onChange({ - filterableIndexPatterns: [{ id: '2', title: 'second index' }], - doc: ({ id: undefined, expression: 'valid expression' } as unknown) as Document, + filterableIndexPatterns: ['2'], + doc: ({ id: undefined } as unknown) as Document, + isSaveable: true, }); }); @@ -1044,11 +1054,11 @@ describe('Lens App', () => { (defaultArgs.docStorage.load as jest.Mock).mockResolvedValue({ id: '1234', title: 'My cool doc', - expression: 'valid expression', state: { query: 'kuery', - datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] }, + filters: [], }, + references: [], } as jest.ResolvedValue); }); @@ -1080,7 +1090,12 @@ describe('Lens App', () => { act(() => onChange({ filterableIndexPatterns: [], - doc: ({ id: undefined, expression: 'valid expression' } as unknown) as Document, + doc: ({ + id: undefined, + + references: [], + } as unknown) as Document, + isSaveable: true, }) ); instance.update(); @@ -1101,7 +1116,8 @@ describe('Lens App', () => { act(() => onChange({ filterableIndexPatterns: [], - doc: ({ id: undefined, expression: 'valid expression' } as unknown) as Document, + doc: ({ id: undefined, state: {} } as unknown) as Document, + isSaveable: true, }) ); instance.update(); @@ -1125,7 +1141,12 @@ describe('Lens App', () => { act(() => onChange({ filterableIndexPatterns: [], - doc: ({ id: '1234', expression: 'different expression' } as unknown) as Document, + doc: ({ + id: '1234', + + references: [], + } as unknown) as Document, + isSaveable: true, }) ); instance.update(); @@ -1149,7 +1170,16 @@ describe('Lens App', () => { act(() => onChange({ filterableIndexPatterns: [], - doc: ({ id: '1234', expression: 'valid expression' } as unknown) as Document, + doc: ({ + id: '1234', + title: 'My cool doc', + references: [], + state: { + query: 'kuery', + filters: [], + }, + } as unknown) as Document, + isSaveable: true, }) ); instance.update(); @@ -1173,7 +1203,8 @@ describe('Lens App', () => { act(() => onChange({ filterableIndexPatterns: [], - doc: ({ id: '1234', expression: null } as unknown) as Document, + doc: ({ id: '1234', references: [] } as unknown) as Document, + isSaveable: true, }) ); instance.update(); diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index b20fe2f804683..31743e5b56b3a 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -27,7 +27,7 @@ import { OnSaveProps, checkForDuplicateTitle, } from '../../../../../src/plugins/saved_objects/public'; -import { Document, SavedObjectStore } from '../persistence'; +import { Document, SavedObjectStore, injectFilterReferences } from '../persistence'; import { EditorFrameInstance } from '../types'; import { NativeRenderer } from '../native_renderer'; import { trackUiEvent } from '../lens_ui_telemetry'; @@ -56,6 +56,7 @@ interface State { query: Query; filters: Filter[]; savedQuery?: SavedQuery; + isSaveable: boolean; } export function App({ @@ -99,6 +100,7 @@ export function App({ originatingApp, filters: data.query.filterManager.getFilters(), indicateNoData: false, + isSaveable: false, }; }); @@ -121,11 +123,7 @@ export function App({ const { lastKnownDoc } = state; - const isSaveable = - lastKnownDoc && - lastKnownDoc.expression && - lastKnownDoc.expression.length > 0 && - core.application.capabilities.visualize.save; + const savingPermitted = state.isSaveable && core.application.capabilities.visualize.save; useEffect(() => { // Clear app-specific filters when navigating to Lens. Necessary because Lens @@ -176,15 +174,34 @@ export function App({ history, ]); + const getLastKnownDocWithoutPinnedFilters = useCallback( + function () { + if (!lastKnownDoc) return undefined; + const [pinnedFilters, appFilters] = _.partition( + injectFilterReferences(lastKnownDoc.state?.filters || [], lastKnownDoc.references), + esFilters.isFilterPinned + ); + return pinnedFilters?.length + ? { + ...lastKnownDoc, + state: { + ...lastKnownDoc.state, + filters: appFilters, + }, + } + : lastKnownDoc; + }, + [lastKnownDoc] + ); + useEffect(() => { onAppLeave((actions) => { // Confirm when the user has made any changes to an existing doc // or when the user has configured something without saving if ( core.application.capabilities.visualize.save && - (state.persistedDoc?.expression - ? !_.isEqual(lastKnownDoc?.expression, state.persistedDoc.expression) - : lastKnownDoc?.expression) + !_.isEqual(state.persistedDoc?.state, getLastKnownDocWithoutPinnedFilters()?.state) && + (state.isSaveable || state.persistedDoc) ) { return actions.confirm( i18n.translate('xpack.lens.app.unsavedWorkMessage', { @@ -198,7 +215,14 @@ export function App({ return actions.default(); } }); - }, [lastKnownDoc, onAppLeave, state.persistedDoc, core.application.capabilities.visualize.save]); + }, [ + lastKnownDoc, + onAppLeave, + state.persistedDoc, + state.isSaveable, + core.application.capabilities.visualize.save, + getLastKnownDocWithoutPinnedFilters, + ]); // Sync Kibana breadcrumbs any time the saved document's title changes useEffect(() => { @@ -229,13 +253,17 @@ export function App({ .load(docId) .then((doc) => { getAllIndexPatterns( - doc.state.datasourceMetaData.filterableIndexPatterns, + _.uniq( + doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id) + ), data.indexPatterns, core.notifications ) .then((indexPatterns) => { // Don't overwrite any pinned filters - data.query.filterManager.setAppFilters(doc.state.filters); + data.query.filterManager.setAppFilters( + injectFilterReferences(doc.state.filters, doc.references) + ); setState((s) => ({ ...s, isLoading: false, @@ -245,13 +273,13 @@ export function App({ indexPatternsForTopNav: indexPatterns, })); }) - .catch(() => { + .catch((e) => { setState((s) => ({ ...s, isLoading: false })); redirectTo(); }); }) - .catch(() => { + .catch((e) => { setState((s) => ({ ...s, isLoading: false })); core.notifications.toasts.addDanger( @@ -287,22 +315,9 @@ export function App({ if (!lastKnownDoc) { return; } - const [pinnedFilters, appFilters] = _.partition( - lastKnownDoc.state?.filters, - esFilters.isFilterPinned - ); - const lastDocWithoutPinned = pinnedFilters?.length - ? { - ...lastKnownDoc, - state: { - ...lastKnownDoc.state, - filters: appFilters, - }, - } - : lastKnownDoc; const doc = { - ...lastDocWithoutPinned, + ...getLastKnownDocWithoutPinnedFilters()!, description: saveProps.newDescription, id: saveProps.newCopyOnSave ? undefined : lastKnownDoc.id, title: saveProps.newTitle, @@ -392,7 +407,7 @@ export function App({ emphasize: true, iconType: 'check', run: () => { - if (isSaveable && lastKnownDoc) { + if (savingPermitted) { runSave({ newTitle: lastKnownDoc.title, newCopyOnSave: false, @@ -402,7 +417,7 @@ export function App({ } }, testId: 'lnsApp_saveAndReturnButton', - disableButton: !isSaveable, + disableButton: !savingPermitted, }, ] : []), @@ -417,12 +432,12 @@ export function App({ }), emphasize: !state.originatingApp || !lastKnownDoc?.id, run: () => { - if (isSaveable && lastKnownDoc) { + if (savingPermitted) { setState((s) => ({ ...s, isSaveModalVisible: true })); } }, testId: 'lnsApp_saveButton', - disableButton: !isSaveable, + disableButton: !savingPermitted, }, ]} data-test-subj="lnsApp_topNav" @@ -503,7 +518,10 @@ export function App({ doc: state.persistedDoc, onError, showNoDataPopover, - onChange: ({ filterableIndexPatterns, doc }) => { + onChange: ({ filterableIndexPatterns, doc, isSaveable }) => { + if (isSaveable !== state.isSaveable) { + setState((s) => ({ ...s, isSaveable })); + } if (!_.isEqual(state.persistedDoc, doc)) { setState((s) => ({ ...s, lastKnownDoc: doc })); } @@ -511,8 +529,8 @@ export function App({ // Update the cached index patterns if the user made a change to any of them if ( state.indexPatternsForTopNav.length !== filterableIndexPatterns.length || - filterableIndexPatterns.find( - ({ id }) => + filterableIndexPatterns.some( + (id) => !state.indexPatternsForTopNav.find((indexPattern) => indexPattern.id === id) ) ) { @@ -554,12 +572,12 @@ export function App({ } export async function getAllIndexPatterns( - ids: Array<{ id: string }>, + ids: string[], indexPatternsService: IndexPatternsContract, notifications: NotificationsStart ): Promise { try { - return await Promise.all(ids.map(({ id }) => indexPatternsService.get(id))); + return await Promise.all(ids.map((id) => indexPatternsService.get(id))); } catch (e) { notifications.toasts.addDanger( i18n.translate('xpack.lens.app.indexPatternLoadingError', { diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 0b6584277ffa7..194f12cf9291b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -50,20 +50,6 @@ describe('Datatable Visualization', () => { }); }); - describe('#getPersistableState', () => { - it('should persist the internal state', () => { - const expectedState: DatatableVisualizationState = { - layers: [ - { - layerId: 'baz', - columns: ['a', 'b', 'c'], - }, - ], - }; - expect(datatableVisualization.getPersistableState(expectedState)).toEqual(expectedState); - }); - }); - describe('#getLayerIds', () => { it('return the layer ids', () => { const state: DatatableVisualizationState = { @@ -340,7 +326,10 @@ describe('Datatable Visualization', () => { label: 'label', }); - const expression = datatableVisualization.toExpression({ layers: [layer] }, frame) as Ast; + const expression = datatableVisualization.toExpression( + { layers: [layer] }, + frame.datasourceLayers + ) as Ast; const tableArgs = buildExpression(expression).findFunction('lens_datatable_columns'); expect(tableArgs).toHaveLength(1); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 659f8ea12bcb0..5aff4e14b17f2 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -25,10 +25,7 @@ function newLayerState(layerId: string): LayerState { }; } -export const datatableVisualization: Visualization< - DatatableVisualizationState, - DatatableVisualizationState -> = { +export const datatableVisualization: Visualization = { id: 'lnsDatatable', visualizationTypes: [ @@ -75,8 +72,6 @@ export const datatableVisualization: Visualization< ); }, - getPersistableState: (state) => state, - getSuggestions({ table, state, @@ -186,9 +181,9 @@ export const datatableVisualization: Visualization< }; }, - toExpression(state, frame): Ast { + toExpression(state, datasourceLayers): Ast { const layer = state.layers[0]; - const datasource = frame.datasourceLayers[layer.layerId]; + const datasource = datasourceLayers[layer.layerId]; const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); // When we add a column it could be empty, and therefore have no order const sortedColumns = Array.from(new Set(originalOrder.concat(layer.columns))); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/__mocks__/expression_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/__mocks__/expression_helpers.ts new file mode 100644 index 0000000000000..e0b3616315cbd --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/__mocks__/expression_helpers.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Ast } from '@kbn/interpreter/common'; + +export function buildExpression(): Ast { + return { + type: 'expression', + chain: [{ type: 'function', function: 'test', arguments: {} }], + }; +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 38224bf962a3f..b2804cfddba58 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -124,7 +124,6 @@ export function LayerPanel( const nextPublicAPI = layerDatasource.getPublicAPI({ state: newState, layerId, - dateRange: props.framePublicAPI.dateRange, }); const nextTable = new Set( nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 2f7a78197b2b2..e628ea0675a8d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -170,25 +170,22 @@ describe('editor_frame', () => { doc={{ visualizationType: 'testVis', title: '', - expression: '', state: { datasourceStates: { testDatasource: datasource1State, testDatasource2: datasource2State, }, visualization: {}, - datasourceMetaData: { - filterableIndexPatterns: [], - }, query: { query: '', language: 'lucene' }, filters: [], }, + references: [], }} /> ); }); - expect(mockDatasource.initialize).toHaveBeenCalledWith(datasource1State); - expect(mockDatasource2.initialize).toHaveBeenCalledWith(datasource2State); + expect(mockDatasource.initialize).toHaveBeenCalledWith(datasource1State, []); + expect(mockDatasource2.initialize).toHaveBeenCalledWith(datasource2State, []); expect(mockDatasource3.initialize).not.toHaveBeenCalled(); }); @@ -425,21 +422,6 @@ describe('editor_frame', () => { "function": "kibana", "type": "function", }, - Object { - "arguments": Object { - "filters": Array [ - "[]", - ], - "query": Array [ - "{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}", - ], - "timeRange": Array [ - "{\\"from\\":\\"\\",\\"to\\":\\"\\"}", - ], - }, - "function": "kibana_context", - "type": "function", - }, Object { "arguments": Object { "layerIds": Array [ @@ -499,19 +481,16 @@ describe('editor_frame', () => { doc={{ visualizationType: 'testVis', title: '', - expression: '', state: { datasourceStates: { testDatasource: {}, testDatasource2: {}, }, visualization: {}, - datasourceMetaData: { - filterableIndexPatterns: [], - }, query: { query: '', language: 'lucene' }, filters: [], }, + references: [], }} /> ); @@ -535,21 +514,6 @@ describe('editor_frame', () => { "function": "kibana", "type": "function", }, - Object { - "arguments": Object { - "filters": Array [ - "[]", - ], - "query": Array [ - "{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}", - ], - "timeRange": Array [ - "{\\"from\\":\\"\\",\\"to\\":\\"\\"}", - ], - }, - "function": "kibana_context", - "type": "function", - }, Object { "arguments": Object { "layerIds": Array [ @@ -747,19 +711,16 @@ describe('editor_frame', () => { doc={{ visualizationType: 'testVis', title: '', - expression: '', state: { datasourceStates: { testDatasource: {}, testDatasource2: {}, }, visualization: {}, - datasourceMetaData: { - filterableIndexPatterns: [], - }, query: { query: '', language: 'lucene' }, filters: [], }, + references: [], }} /> ); @@ -802,19 +763,16 @@ describe('editor_frame', () => { doc={{ visualizationType: 'testVis', title: '', - expression: '', state: { datasourceStates: { testDatasource: datasource1State, testDatasource2: datasource2State, }, visualization: {}, - datasourceMetaData: { - filterableIndexPatterns: [], - }, query: { query: '', language: 'lucene' }, filters: [], }, + references: [], }} /> ); @@ -842,7 +800,6 @@ describe('editor_frame', () => { it('should give access to the datasource state in the datasource factory function', async () => { const datasourceState = {}; - const dateRange = { fromDate: 'now-1w', toDate: 'now' }; mockDatasource.initialize.mockResolvedValue(datasourceState); mockDatasource.getLayers.mockReturnValue(['first']); @@ -850,7 +807,6 @@ describe('editor_frame', () => { mount( { }); expect(mockDatasource.getPublicAPI).toHaveBeenCalledWith({ - dateRange, state: datasourceState, layerId: 'first', }); @@ -1460,9 +1415,10 @@ describe('editor_frame', () => { }) ); mockDatasource.getLayers.mockReturnValue(['first']); - mockDatasource.getMetaData.mockReturnValue({ - filterableIndexPatterns: [{ id: '1', title: 'resolved' }], - }); + mockDatasource.getPersistableState = jest.fn((x) => ({ + state: x, + savedObjectReferences: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], + })); mockVisualization.initialize.mockReturnValue({ initialState: true }); await act(async () => { @@ -1487,14 +1443,20 @@ describe('editor_frame', () => { expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenNthCalledWith(1, { - filterableIndexPatterns: [{ id: '1', title: 'resolved' }], + filterableIndexPatterns: ['1'], doc: { - expression: '', id: undefined, + description: undefined, + references: [ + { + id: '1', + name: 'index-pattern-0', + type: 'index-pattern', + }, + ], state: { visualization: null, // Not yet loaded - datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'resolved' }] }, - datasourceStates: { testDatasource: undefined }, + datasourceStates: { testDatasource: {} }, query: { query: '', language: 'lucene' }, filters: [], }, @@ -1502,18 +1464,23 @@ describe('editor_frame', () => { type: 'lens', visualizationType: 'testVis', }, + isSaveable: false, }); expect(onChange).toHaveBeenLastCalledWith({ - filterableIndexPatterns: [{ id: '1', title: 'resolved' }], + filterableIndexPatterns: ['1'], doc: { - expression: '', + references: [ + { + id: '1', + name: 'index-pattern-0', + type: 'index-pattern', + }, + ], + description: undefined, id: undefined, state: { visualization: { initialState: true }, // Now loaded - datasourceMetaData: { - filterableIndexPatterns: [{ id: '1', title: 'resolved' }], - }, - datasourceStates: { testDatasource: undefined }, + datasourceStates: { testDatasource: {} }, query: { query: '', language: 'lucene' }, filters: [], }, @@ -1521,6 +1488,7 @@ describe('editor_frame', () => { type: 'lens', visualizationType: 'testVis', }, + isSaveable: false, }); }); @@ -1562,11 +1530,10 @@ describe('editor_frame', () => { expect(onChange).toHaveBeenNthCalledWith(3, { filterableIndexPatterns: [], doc: { - expression: expect.stringContaining('vis "expression"'), id: undefined, + references: [], state: { - datasourceMetaData: { filterableIndexPatterns: [] }, - datasourceStates: { testDatasource: undefined }, + datasourceStates: { testDatasource: { datasource: '' } }, visualization: { initialState: true }, query: { query: 'new query', language: 'lucene' }, filters: [], @@ -1575,6 +1542,7 @@ describe('editor_frame', () => { type: 'lens', visualizationType: 'testVis', }, + isSaveable: true, }); }); @@ -1583,9 +1551,10 @@ describe('editor_frame', () => { mockDatasource.initialize.mockResolvedValue({}); mockDatasource.getLayers.mockReturnValue(['first']); - mockDatasource.getMetaData.mockReturnValue({ - filterableIndexPatterns: [{ id: '1', title: 'resolved' }], - }); + mockDatasource.getPersistableState = jest.fn((x) => ({ + state: x, + savedObjectReferences: [{ type: 'index-pattern', id: '1', name: '' }], + })); mockVisualization.initialize.mockReturnValue({ initialState: true }); await act(async () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 48a3511a8f359..72ad8e074226c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -7,13 +7,7 @@ import React, { useEffect, useReducer } from 'react'; import { CoreSetup, CoreStart } from 'kibana/public'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; -import { - Datasource, - DatasourcePublicAPI, - FramePublicAPI, - Visualization, - DatasourceMetaData, -} from '../../types'; +import { Datasource, FramePublicAPI, Visualization } from '../../types'; import { reducer, getInitialState } from './state_management'; import { DataPanelWrapper } from './data_panel_wrapper'; import { ConfigPanelWrapper } from './config_panel'; @@ -26,6 +20,7 @@ import { getSavedObjectFormat } from './save'; import { generateId } from '../../id_generator'; import { Filter, Query, SavedQuery } from '../../../../../../src/plugins/data/public'; import { EditorFrameStartPlugins } from '../service'; +import { initializeDatasources, createDatasourceLayers } from './state_helpers'; export interface EditorFrameProps { doc?: Document; @@ -45,8 +40,9 @@ export interface EditorFrameProps { filters: Filter[]; savedQuery?: SavedQuery; onChange: (arg: { - filterableIndexPatterns: DatasourceMetaData['filterableIndexPatterns']; + filterableIndexPatterns: string[]; doc: Document; + isSaveable: boolean; }) => void; showNoDataPopover: () => void; } @@ -67,25 +63,19 @@ export function EditorFrame(props: EditorFrameProps) { // prevents executing dispatch on unmounted component let isUnmounted = false; if (!allLoaded) { - Object.entries(props.datasourceMap).forEach(([datasourceId, datasource]) => { - if ( - state.datasourceStates[datasourceId] && - state.datasourceStates[datasourceId].isLoading - ) { - datasource - .initialize(state.datasourceStates[datasourceId].state || undefined) - .then((datasourceState) => { - if (!isUnmounted) { - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - updater: datasourceState, - datasourceId, - }); - } - }) - .catch(onError); - } - }); + initializeDatasources(props.datasourceMap, state.datasourceStates, props.doc?.references) + .then((result) => { + if (!isUnmounted) { + Object.entries(result).forEach(([datasourceId, { state: datasourceState }]) => { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + updater: datasourceState, + datasourceId, + }); + }); + } + }) + .catch(onError); } return () => { isUnmounted = true; @@ -95,22 +85,7 @@ export function EditorFrame(props: EditorFrameProps) { [allLoaded, onError] ); - const datasourceLayers: Record = {}; - Object.keys(props.datasourceMap) - .filter((id) => state.datasourceStates[id] && !state.datasourceStates[id].isLoading) - .forEach((id) => { - const datasourceState = state.datasourceStates[id].state; - const datasource = props.datasourceMap[id]; - - const layers = datasource.getLayers(datasourceState); - layers.forEach((layer) => { - datasourceLayers[layer] = props.datasourceMap[id].getPublicAPI({ - state: datasourceState, - layerId: layer, - dateRange: props.dateRange, - }); - }); - }); + const datasourceLayers = createDatasourceLayers(props.datasourceMap, state.datasourceStates); const framePublicAPI: FramePublicAPI = { datasourceLayers, @@ -165,7 +140,18 @@ export function EditorFrame(props: EditorFrameProps) { if (props.doc) { dispatch({ type: 'VISUALIZATION_LOADED', - doc: props.doc, + doc: { + ...props.doc, + state: { + ...props.doc.state, + visualization: props.doc.visualizationType + ? props.visualizationMap[props.doc.visualizationType].initialize( + framePublicAPI, + props.doc.state.visualization + ) + : props.doc.state.visualization, + }, + }, }); } else { dispatch({ @@ -206,36 +192,20 @@ export function EditorFrame(props: EditorFrameProps) { return; } - const indexPatterns: DatasourceMetaData['filterableIndexPatterns'] = []; - Object.entries(props.datasourceMap) - .filter(([id, datasource]) => { - const stateWrapper = state.datasourceStates[id]; - return ( - stateWrapper && - !stateWrapper.isLoading && - datasource.getLayers(stateWrapper.state).length > 0 - ); + props.onChange( + getSavedObjectFormat({ + activeDatasources: Object.keys(state.datasourceStates).reduce( + (datasourceMap, datasourceId) => ({ + ...datasourceMap, + [datasourceId]: props.datasourceMap[datasourceId], + }), + {} + ), + visualization: activeVisualization, + state, + framePublicAPI, }) - .forEach(([id, datasource]) => { - indexPatterns.push( - ...datasource.getMetaData(state.datasourceStates[id].state).filterableIndexPatterns - ); - }); - - const doc = getSavedObjectFormat({ - activeDatasources: Object.keys(state.datasourceStates).reduce( - (datasourceMap, datasourceId) => ({ - ...datasourceMap, - [datasourceId]: props.datasourceMap[datasourceId], - }), - {} - ), - visualization: activeVisualization, - state, - framePublicAPI, - }); - - props.onChange({ filterableIndexPatterns: indexPatterns, doc }); + ); }, // eslint-disable-next-line react-hooks/exhaustive-deps [ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts index ee28ccfe1bf53..952718e13c8cf 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts @@ -5,8 +5,7 @@ */ import { Ast, fromExpression, ExpressionFunctionAST } from '@kbn/interpreter/common'; -import { Visualization, Datasource, FramePublicAPI } from '../../types'; -import { Filter, TimeRange, Query } from '../../../../../../src/plugins/data/public'; +import { Visualization, Datasource, DatasourcePublicAPI } from '../../types'; export function prependDatasourceExpression( visualizationExpression: Ast | string | null, @@ -58,40 +57,12 @@ export function prependDatasourceExpression( ? fromExpression(visualizationExpression) : visualizationExpression; - return { - type: 'expression', - chain: [datafetchExpression, ...parsedVisualizationExpression.chain], - }; -} - -export function prependKibanaContext( - expression: Ast | string, - { - timeRange, - query, - filters, - }: { - timeRange?: TimeRange; - query?: Query; - filters?: Filter[]; - } -): Ast { - const parsedExpression = typeof expression === 'string' ? fromExpression(expression) : expression; - return { type: 'expression', chain: [ { type: 'function', function: 'kibana', arguments: {} }, - { - type: 'function', - function: 'kibana_context', - arguments: { - timeRange: timeRange ? [JSON.stringify(timeRange)] : [], - query: query ? [JSON.stringify(query)] : [], - filters: [JSON.stringify(filters || [])], - }, - }, - ...parsedExpression.chain, + datafetchExpression, + ...parsedVisualizationExpression.chain, ], }; } @@ -101,8 +72,7 @@ export function buildExpression({ visualizationState, datasourceMap, datasourceStates, - framePublicAPI, - removeDateRange, + datasourceLayers, }: { visualization: Visualization | null; visualizationState: unknown; @@ -114,24 +84,12 @@ export function buildExpression({ state: unknown; } >; - framePublicAPI: FramePublicAPI; - removeDateRange?: boolean; + datasourceLayers: Record; }): Ast | null { if (visualization === null) { return null; } - const visualizationExpression = visualization.toExpression(visualizationState, framePublicAPI); - - const expressionContext = removeDateRange - ? { query: framePublicAPI.query, filters: framePublicAPI.filters } - : { - query: framePublicAPI.query, - timeRange: { - from: framePublicAPI.dateRange.fromDate, - to: framePublicAPI.dateRange.toDate, - }, - filters: framePublicAPI.filters, - }; + const visualizationExpression = visualization.toExpression(visualizationState, datasourceLayers); const completeExpression = prependDatasourceExpression( visualizationExpression, @@ -139,9 +97,5 @@ export function buildExpression({ datasourceStates ); - if (completeExpression) { - return prependKibanaContext(completeExpression, expressionContext); - } else { - return null; - } + return completeExpression; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts index d72e5c57ce56e..45d24fd30e2fc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts @@ -8,14 +8,18 @@ import { getSavedObjectFormat, Props } from './save'; import { createMockDatasource, createMockVisualization } from '../mocks'; import { esFilters, IIndexPattern, IFieldType } from '../../../../../../src/plugins/data/public'; +jest.mock('./expression_helpers'); + describe('save editor frame state', () => { const mockVisualization = createMockVisualization(); - mockVisualization.getPersistableState.mockImplementation((x) => x); const mockDatasource = createMockDatasource('a'); const mockIndexPattern = ({ id: 'indexpattern' } as unknown) as IIndexPattern; const mockField = ({ name: '@timestamp' } as unknown) as IFieldType; - mockDatasource.getPersistableState.mockImplementation((x) => x); + mockDatasource.getPersistableState.mockImplementation((x) => ({ + state: x, + savedObjectReferences: [], + })); const saveArgs: Props = { activeDatasources: { indexpattern: mockDatasource, @@ -47,15 +51,17 @@ describe('save editor frame state', () => { it('transforms from internal state to persisted doc format', async () => { const datasource = createMockDatasource('a'); datasource.getPersistableState.mockImplementation((state) => ({ - stuff: `${state}_datasource_persisted`, + state: { + stuff: `${state}_datasource_persisted`, + }, + savedObjectReferences: [], })); + datasource.toExpression.mockReturnValue('my | expr'); const visualization = createMockVisualization(); - visualization.getPersistableState.mockImplementation((state) => ({ - things: `${state}_vis_persisted`, - })); + visualization.toExpression.mockReturnValue('vis | expr'); - const doc = await getSavedObjectFormat({ + const { doc, filterableIndexPatterns, isSaveable } = await getSavedObjectFormat({ ...saveArgs, activeDatasources: { indexpattern: datasource, @@ -74,27 +80,32 @@ describe('save editor frame state', () => { visualization, }); + expect(filterableIndexPatterns).toEqual([]); + expect(isSaveable).toEqual(true); expect(doc).toEqual({ id: undefined, - expression: '', state: { - datasourceMetaData: { - filterableIndexPatterns: [], - }, datasourceStates: { indexpattern: { stuff: '2_datasource_persisted', }, }, - visualization: { things: '4_vis_persisted' }, + visualization: '4', query: { query: '', language: 'lucene' }, filters: [ { - meta: { index: 'indexpattern' }, + meta: { indexRefName: 'filter-index-pattern-0' }, exists: { field: '@timestamp' }, }, ], }, + references: [ + { + id: 'indexpattern', + name: 'filter-index-pattern-0', + type: 'index-pattern', + }, + ], title: 'bbb', type: 'lens', visualizationType: '3', diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts index b41e93def966e..6da6d5a8c118f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts @@ -5,11 +5,12 @@ */ import _ from 'lodash'; -import { toExpression } from '@kbn/interpreter/target/common'; +import { SavedObjectReference } from 'kibana/public'; import { EditorFrameState } from './state_management'; import { Document } from '../../persistence/saved_object_store'; -import { buildExpression } from './expression_helpers'; import { Datasource, Visualization, FramePublicAPI } from '../../types'; +import { extractFilterReferences } from '../../persistence'; +import { buildExpression } from './expression_helpers'; export interface Props { activeDatasources: Record; @@ -23,43 +24,55 @@ export function getSavedObjectFormat({ state, visualization, framePublicAPI, -}: Props): Document { +}: Props): { + doc: Document; + filterableIndexPatterns: string[]; + isSaveable: boolean; +} { + const datasourceStates: Record = {}; + const references: SavedObjectReference[] = []; + Object.entries(activeDatasources).forEach(([id, datasource]) => { + const { state: persistableState, savedObjectReferences } = datasource.getPersistableState( + state.datasourceStates[id].state + ); + datasourceStates[id] = persistableState; + references.push(...savedObjectReferences); + }); + + const uniqueFilterableIndexPatternIds = _.uniq( + references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id) + ); + + const { persistableFilters, references: filterReferences } = extractFilterReferences( + framePublicAPI.filters + ); + + references.push(...filterReferences); + const expression = buildExpression({ visualization, visualizationState: state.visualization.state, datasourceMap: activeDatasources, datasourceStates: state.datasourceStates, - framePublicAPI, - removeDateRange: true, - }); - - const datasourceStates: Record = {}; - Object.entries(activeDatasources).forEach(([id, datasource]) => { - datasourceStates[id] = datasource.getPersistableState(state.datasourceStates[id].state); - }); - - const filterableIndexPatterns: Array<{ id: string; title: string }> = []; - Object.entries(activeDatasources).forEach(([id, datasource]) => { - filterableIndexPatterns.push( - ...datasource.getMetaData(state.datasourceStates[id].state).filterableIndexPatterns - ); + datasourceLayers: framePublicAPI.datasourceLayers, }); return { - id: state.persistedId, - title: state.title, - description: state.description, - type: 'lens', - visualizationType: state.visualization.activeId, - expression: expression ? toExpression(expression) : '', - state: { - datasourceStates, - datasourceMetaData: { - filterableIndexPatterns: _.uniqBy(filterableIndexPatterns, 'id'), + doc: { + id: state.persistedId, + title: state.title, + description: state.description, + type: 'lens', + visualizationType: state.visualization.activeId, + state: { + datasourceStates, + visualization: state.visualization.state, + query: framePublicAPI.query, + filters: persistableFilters, }, - visualization: visualization.getPersistableState(state.visualization.state), - query: framePublicAPI.query, - filters: framePublicAPI.filters, + references, }, + filterableIndexPatterns: uniqueFilterableIndexPatternIds, + isSaveable: expression !== null, }; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts new file mode 100644 index 0000000000000..6deb9ffd37a06 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectReference } from 'kibana/public'; +import { Ast } from '@kbn/interpreter/common'; +import { Datasource, DatasourcePublicAPI, Visualization } from '../../types'; +import { buildExpression } from './expression_helpers'; +import { Document } from '../../persistence/saved_object_store'; + +export async function initializeDatasources( + datasourceMap: Record, + datasourceStates: Record, + references?: SavedObjectReference[] +) { + const states: Record = {}; + await Promise.all( + Object.entries(datasourceMap).map(([datasourceId, datasource]) => { + if (datasourceStates[datasourceId]) { + return datasource + .initialize(datasourceStates[datasourceId].state || undefined, references) + .then((datasourceState) => { + states[datasourceId] = { isLoading: false, state: datasourceState }; + }); + } + }) + ); + return states; +} + +export function createDatasourceLayers( + datasourceMap: Record, + datasourceStates: Record +) { + const datasourceLayers: Record = {}; + Object.keys(datasourceMap) + .filter((id) => datasourceStates[id] && !datasourceStates[id].isLoading) + .forEach((id) => { + const datasourceState = datasourceStates[id].state; + const datasource = datasourceMap[id]; + + const layers = datasource.getLayers(datasourceState); + layers.forEach((layer) => { + datasourceLayers[layer] = datasourceMap[id].getPublicAPI({ + state: datasourceState, + layerId: layer, + }); + }); + }); + return datasourceLayers; +} + +export async function persistedStateToExpression( + datasources: Record, + visualizations: Record, + doc: Document +): Promise { + const { + state: { visualization: visualizationState, datasourceStates: persistedDatasourceStates }, + visualizationType, + references, + } = doc; + if (!visualizationType) return null; + const visualization = visualizations[visualizationType!]; + const datasourceStates = await initializeDatasources( + datasources, + Object.fromEntries( + Object.entries(persistedDatasourceStates).map(([id, state]) => [ + id, + { isLoading: false, state }, + ]) + ), + references + ); + + const datasourceLayers = createDatasourceLayers(datasources, datasourceStates); + + return buildExpression({ + visualization, + visualizationState, + datasourceMap: datasources, + datasourceStates, + datasourceLayers, + }); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts index 969467b5789ec..c7f505aeca517 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts @@ -57,19 +57,16 @@ describe('editor_frame state management', () => { const initialState = getInitialState({ ...props, doc: { - expression: '', state: { datasourceStates: { testDatasource: { internalState1: '' }, testDatasource2: { internalState2: '' }, }, visualization: {}, - datasourceMetaData: { - filterableIndexPatterns: [], - }, query: { query: '', language: 'lucene' }, filters: [], }, + references: [], title: '', visualizationType: 'testVis', }, @@ -380,9 +377,7 @@ describe('editor_frame state management', () => { type: 'VISUALIZATION_LOADED', doc: { id: 'b', - expression: '', state: { - datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { a: { foo: 'c' } }, visualization: { bar: 'd' }, query: { query: '', language: 'lucene' }, @@ -392,6 +387,7 @@ describe('editor_frame state management', () => { description: 'My lens', type: 'lens', visualizationType: 'line', + references: [], }, } ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index 263f7cd65f43d..2bb1baf9d54f2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -107,7 +107,7 @@ export function getSuggestions({ * title and preview expression. */ function getVisualizationSuggestions( - visualization: Visualization, + visualization: Visualization, table: TableSuggestion, visualizationId: string, datasourceSuggestion: DatasourceSuggestion & { datasourceId: string }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index fd509c0046e13..323472d717352 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -249,7 +249,6 @@ describe('suggestion_panel', () => { expect(passedExpression).toMatchInlineSnapshot(` "kibana - | kibana_context timeRange=\\"{\\\\\\"from\\\\\\":\\\\\\"now-7d\\\\\\",\\\\\\"to\\\\\\":\\\\\\"now\\\\\\"}\\" query=\\"{\\\\\\"query\\\\\\":\\\\\\"\\\\\\",\\\\\\"language\\\\\\":\\\\\\"lucene\\\\\\"}\\" filters=\\"[{\\\\\\"meta\\\\\\":{\\\\\\"index\\\\\\":\\\\\\"index1\\\\\\"},\\\\\\"exists\\\\\\":{\\\\\\"field\\\\\\":\\\\\\"myfield\\\\\\"}}]\\" | lens_merge_tables layerIds=\\"first\\" tables={datasource_expression} | test | expression" diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 7395075cf9f74..f1dc3fa306d15 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -21,6 +21,7 @@ import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Ast, toExpression } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; import classNames from 'classnames'; +import { ExecutionContextSearch } from 'src/plugins/expressions'; import { Action, PreviewState } from './state_management'; import { Datasource, Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types'; import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; @@ -28,7 +29,7 @@ import { ReactExpressionRendererProps, ReactExpressionRendererType, } from '../../../../../../src/plugins/expressions/public'; -import { prependDatasourceExpression, prependKibanaContext } from './expression_helpers'; +import { prependDatasourceExpression } from './expression_helpers'; import { debouncedComponent } from '../../debounced_component'; import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; @@ -112,7 +113,7 @@ const SuggestionPreview = ({ }: { onSelect: () => void; preview: { - expression?: Ast; + expression?: Ast | null; icon: IconType; title: string; }; @@ -215,12 +216,24 @@ export function SuggestionPanel({ visualizationMap, ]); + const context: ExecutionContextSearch = useMemo( + () => ({ + query: frame.query, + timeRange: { + from: frame.dateRange.fromDate, + to: frame.dateRange.toDate, + }, + filters: frame.filters, + }), + [frame.query, frame.dateRange.fromDate, frame.dateRange.toDate, frame.filters] + ); + const AutoRefreshExpressionRenderer = useMemo(() => { const autoRefreshFetch$ = plugins.data.query.timefilter.timefilter.getAutoRefreshFetch$(); return (props: ReactExpressionRendererProps) => ( - + ); - }, [plugins.data.query.timefilter.timefilter]); + }, [plugins.data.query.timefilter.timefilter, context]); const [lastSelectedSuggestion, setLastSelectedSuggestion] = useState(-1); @@ -252,15 +265,6 @@ export function SuggestionPanel({ } } - const expressionContext = { - query: frame.query, - filters: frame.filters, - timeRange: { - from: frame.dateRange.fromDate, - to: frame.dateRange.toDate, - }, - }; - return (
@@ -305,9 +309,7 @@ export function SuggestionPanel({ {currentVisualizationId && ( , + newVisualization: Visualization, subVisualizationId?: string ): Suggestion | undefined { const unfilteredSuggestions = getSuggestions({ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index a9c638df8cad1..47e3b41df3b21 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -172,21 +172,6 @@ describe('workspace_panel', () => { "function": "kibana", "type": "function", }, - Object { - "arguments": Object { - "filters": Array [ - "[]", - ], - "query": Array [ - "{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}", - ], - "timeRange": Array [ - "{\\"from\\":\\"now-7d\\",\\"to\\":\\"now\\"}", - ], - }, - "function": "kibana_context", - "type": "function", - }, Object { "arguments": Object { "layerIds": Array [ @@ -305,10 +290,10 @@ describe('workspace_panel', () => { ); expect( - (instance.find(expressionRendererMock).prop('expression') as Ast).chain[2].arguments.layerIds + (instance.find(expressionRendererMock).prop('expression') as Ast).chain[1].arguments.layerIds ).toEqual(['first', 'second', 'third']); expect( - (instance.find(expressionRendererMock).prop('expression') as Ast).chain[2].arguments.tables + (instance.find(expressionRendererMock).prop('expression') as Ast).chain[1].arguments.tables ).toMatchInlineSnapshot(` Array [ Object { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index b3a12271f377b..4f914bc65dc7c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -18,6 +18,7 @@ import { EuiLink, } from '@elastic/eui'; import { CoreStart, CoreSetup } from 'kibana/public'; +import { ExecutionContextSearch } from 'src/plugins/expressions'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -129,7 +130,7 @@ export function InnerWorkspacePanel({ visualizationState, datasourceMap, datasourceStates, - framePublicAPI, + datasourceLayers: framePublicAPI.datasourceLayers, }); } catch (e) { // Most likely an error in the expression provided by a datasource or visualization @@ -173,6 +174,23 @@ export function InnerWorkspacePanel({ [plugins.data.query.timefilter.timefilter] ); + const context: ExecutionContextSearch = useMemo( + () => ({ + query: framePublicAPI.query, + timeRange: { + from: framePublicAPI.dateRange.fromDate, + to: framePublicAPI.dateRange.toDate, + }, + filters: framePublicAPI.filters, + }), + [ + framePublicAPI.query, + framePublicAPI.dateRange.fromDate, + framePublicAPI.dateRange.toDate, + framePublicAPI.filters, + ] + ); + useEffect(() => { // reset expression error if component attempts to run it again if (expression && localState.expressionBuildError) { @@ -264,6 +282,7 @@ export function InnerWorkspacePanel({ className="lnsExpressionRenderer__component" padding="m" expression={expression!} + searchContext={context} reload$={autoRefreshFetch$} onEvent={onEvent} renderError={(errorMessage?: string | null) => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index 69447b3b9a9b8..1e2df28cad7b1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -18,16 +18,13 @@ jest.mock('../../../../../../src/plugins/inspector/public/', () => ({ })); const savedVis: Document = { - expression: 'my | expression', state: { visualization: {}, datasourceStates: {}, - datasourceMetaData: { - filterableIndexPatterns: [], - }, query: { query: '', language: 'lucene' }, filters: [], }, + references: [], title: 'My title', visualizationType: '', }; @@ -59,13 +56,14 @@ describe('embeddable', () => { editUrl: '', editable: true, savedVis, + expression: 'my | expression', }, { id: '123' } ); embeddable.render(mountpoint); expect(expressionRenderer).toHaveBeenCalledTimes(1); - expect(expressionRenderer.mock.calls[0][0]!.expression).toEqual(savedVis.expression); + expect(expressionRenderer.mock.calls[0][0]!.expression).toEqual('my | expression'); }); it('should re-render if new input is pushed', () => { @@ -82,6 +80,7 @@ describe('embeddable', () => { editUrl: '', editable: true, savedVis, + expression: 'my | expression', }, { id: '123' } ); @@ -110,6 +109,7 @@ describe('embeddable', () => { editUrl: '', editable: true, savedVis, + expression: 'my | expression', }, { id: '123', timeRange, query, filters } ); @@ -117,11 +117,52 @@ describe('embeddable', () => { expect(expressionRenderer.mock.calls[0][0].searchContext).toEqual({ timeRange, - query, + query: [query, savedVis.state.query], filters, }); }); + it('should merge external context with query and filters of the saved object', () => { + const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; + const query: Query = { language: 'kquery', query: 'external filter' }; + const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }]; + + const embeddable = new Embeddable( + dataPluginMock.createSetupContract().query.timefilter.timefilter, + expressionRenderer, + getTrigger, + { + editPath: '', + editUrl: '', + editable: true, + savedVis: { + ...savedVis, + state: { + ...savedVis.state, + query: { language: 'kquery', query: 'saved filter' }, + filters: [ + { meta: { alias: 'test', negate: false, disabled: false, indexRefName: 'filter-0' } }, + ], + }, + references: [{ type: 'index-pattern', name: 'filter-0', id: 'my-index-pattern-id' }], + }, + expression: 'my | expression', + }, + { id: '123', timeRange, query, filters } + ); + embeddable.render(mountpoint); + + expect(expressionRenderer.mock.calls[0][0].searchContext).toEqual({ + timeRange, + query: [query, { language: 'kquery', query: 'saved filter' }], + filters: [ + filters[0], + // actual index pattern id gets injected + { meta: { alias: 'test', negate: false, disabled: false, index: 'my-index-pattern-id' } }, + ], + }); + }); + it('should execute trigger on event from expression renderer', () => { const embeddable = new Embeddable( dataPluginMock.createSetupContract().query.timefilter.timefilter, @@ -132,6 +173,7 @@ describe('embeddable', () => { editUrl: '', editable: true, savedVis, + expression: 'my | expression', }, { id: '123' } ); @@ -162,6 +204,7 @@ describe('embeddable', () => { editUrl: '', editable: true, savedVis, + expression: 'my | expression', }, { id: '123', timeRange, query, filters } ); @@ -195,6 +238,7 @@ describe('embeddable', () => { editUrl: '', editable: true, savedVis, + expression: 'my | expression', }, { id: '123', timeRange, query, filters } ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index bbd2b18907e9b..4df218a3e94e9 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -14,6 +14,7 @@ import { TimefilterContract, TimeRange, } from 'src/plugins/data/public'; +import { ExecutionContextSearch } from 'src/plugins/expressions'; import { Subscription } from 'rxjs'; import { @@ -28,12 +29,13 @@ import { EmbeddableOutput, IContainer, } from '../../../../../../src/plugins/embeddable/public'; -import { DOC_TYPE, Document } from '../../persistence'; +import { DOC_TYPE, Document, injectFilterReferences } from '../../persistence'; import { ExpressionWrapper } from './expression_wrapper'; import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; import { isLensBrushEvent, isLensFilterEvent } from '../../types'; export interface LensEmbeddableConfiguration { + expression: string | null; savedVis: Document; editUrl: string; editPath: string; @@ -56,12 +58,13 @@ export class Embeddable extends AbstractEmbeddable this.onContainerStateChanged(input)); this.onContainerStateChanged(initialInput); @@ -122,14 +133,14 @@ export class Embeddable extends AbstractEmbeddable !filter.meta.disabled) : undefined; if ( - !_.isEqual(containerState.timeRange, this.currentContext.timeRange) || - !_.isEqual(containerState.query, this.currentContext.query) || - !_.isEqual(cleanedFilters, this.currentContext.filters) + !_.isEqual(containerState.timeRange, this.externalSearchContext.timeRange) || + !_.isEqual(containerState.query, this.externalSearchContext.query) || + !_.isEqual(cleanedFilters, this.externalSearchContext.filters) ) { - this.currentContext = { + this.externalSearchContext = { timeRange: containerState.timeRange, query: containerState.query, - lastReloadRequestTime: this.currentContext.lastReloadRequestTime, + lastReloadRequestTime: this.externalSearchContext.lastReloadRequestTime, filters: cleanedFilters, }; @@ -149,14 +160,37 @@ export class Embeddable extends AbstractEmbeddable, domNode ); } + /** + * Combines the embeddable context with the saved object context, and replaces + * any references to index patterns + */ + private getMergedSearchContext(): ExecutionContextSearch { + const output: ExecutionContextSearch = { + timeRange: this.externalSearchContext.timeRange, + }; + if (this.externalSearchContext.query) { + output.query = [this.externalSearchContext.query, this.savedVis.state.query]; + } else { + output.query = [this.savedVis.state.query]; + } + if (this.externalSearchContext.filters?.length) { + output.filters = [...this.externalSearchContext.filters, ...this.savedVis.state.filters]; + } else { + output.filters = [...this.savedVis.state.filters]; + } + + output.filters = injectFilterReferences(output.filters, this.savedVis.references); + return output; + } + handleEvent = (event: ExpressionRendererEvent) => { if (!this.getTrigger || this.input.disableTriggers) { return; @@ -188,9 +222,9 @@ export class Embeddable extends AbstractEmbeddable Promise; } export class EmbeddableFactory implements EmbeddableFactoryDefinition { @@ -72,13 +75,15 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { indexPatternService, timefilter, expressionRenderer, + documentToExpression, uiActions, } = await this.getStartServices(); const store = new SavedObjectIndexStore(savedObjectsClient); const savedVis = await store.load(savedObjectId); - const promises = savedVis.state.datasourceMetaData.filterableIndexPatterns.map( - async ({ id }) => { + const promises = savedVis.references + .filter(({ type }) => type === 'index-pattern') + .map(async ({ id }) => { try { return await indexPatternService.get(id); } catch (error) { @@ -87,14 +92,15 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { // to show. return null; } - } - ); + }); const indexPatterns = ( await Promise.all(promises) ).filter((indexPattern: IndexPattern | null): indexPattern is IndexPattern => Boolean(indexPattern) ); + const expression = await documentToExpression(savedVis); + return new Embeddable( timefilter, expressionRenderer, @@ -105,6 +111,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { editUrl: coreHttp.basePath.prepend(`/app/lens${getEditPath(savedObjectId)}`), editable: await this.isEditable(), indexPatterns, + expression: expression ? toExpression(expression) : null, }, input, parent diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index 296dcef3e70b9..d0d2360ddc107 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -8,28 +8,23 @@ import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon } from '@elastic/eui'; -import { TimeRange, Filter, Query } from 'src/plugins/data/public'; import { ExpressionRendererEvent, ReactExpressionRendererType, } from 'src/plugins/expressions/public'; +import { ExecutionContextSearch } from 'src/plugins/expressions'; export interface ExpressionWrapperProps { ExpressionRenderer: ReactExpressionRendererType; expression: string | null; - context: { - timeRange?: TimeRange; - query?: Query; - filters?: Filter[]; - lastReloadRequestTime?: number; - }; + searchContext: ExecutionContextSearch; handleEvent: (event: ExpressionRendererEvent) => void; } export function ExpressionWrapper({ ExpressionRenderer: ExpressionRendererComponent, expression, - context, + searchContext, handleEvent, }: ExpressionWrapperProps) { return ( @@ -54,7 +49,7 @@ export function ExpressionWrapper({ className="lnsExpressionRenderer__component" padding="m" expression={expression} - searchContext={{ ...context }} + searchContext={searchContext} renderError={(error) =>
{error}
} onEvent={handleEvent} /> diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx index 9c0825b3c2d27..86b137851d9bd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx @@ -31,7 +31,6 @@ export function createMockVisualization(): jest.Mocked { getVisualizationTypeId: jest.fn((_state) => 'empty'), getDescription: jest.fn((_state) => ({ label: '' })), switchVisualizationType: jest.fn((_, x) => x), - getPersistableState: jest.fn((_state) => _state), getSuggestions: jest.fn((_options) => []), initialize: jest.fn((_frame, _state?) => ({})), getConfiguration: jest.fn((props) => ({ @@ -71,7 +70,7 @@ export function createMockDatasource(id: string): DatasourceMock { clearLayer: jest.fn((state, _layerId) => state), getDatasourceSuggestionsForField: jest.fn((_state, _item) => []), getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []), - getPersistableState: jest.fn(), + getPersistableState: jest.fn((x) => ({ state: x, savedObjectReferences: [] })), getPublicAPI: jest.fn().mockReturnValue(publicAPIMock), initialize: jest.fn((_state?) => Promise.resolve()), renderDataPanel: jest.fn(), @@ -81,7 +80,6 @@ export function createMockDatasource(id: string): DatasourceMock { removeLayer: jest.fn((_state, _layerId) => {}), removeColumn: jest.fn((props) => {}), getLayers: jest.fn((_state) => []), - getMetaData: jest.fn((_state) => ({ filterableIndexPatterns: [] })), renderDimensionTrigger: jest.fn(), renderDimensionEditor: jest.fn(), diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index 47339373b6d1a..5fc347179a032 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -21,12 +21,14 @@ import { EditorFrameInstance, EditorFrameStart, } from '../types'; +import { Document } from '../persistence/saved_object_store'; import { EditorFrame } from './editor_frame'; import { mergeTables } from './merge_tables'; import { formatColumn } from './format_column'; import { EmbeddableFactory } from './embeddable/embeddable_factory'; import { getActiveDatasourceIdFromDoc } from './editor_frame/state_management'; import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { persistedStateToExpression } from './editor_frame/state_helpers'; export interface EditorFrameSetupPlugins { data: DataPublicPluginSetup; @@ -59,6 +61,21 @@ export class EditorFrameService { private readonly datasources: Array> = []; private readonly visualizations: Array> = []; + /** + * This method takes a Lens saved object as returned from the persistence helper, + * initializes datsources and visualization and creates the current expression. + * This is an asynchronous process and should only be triggered once for a saved object. + * @param doc parsed Lens saved object + */ + private async documentToExpression(doc: Document) { + const [resolvedDatasources, resolvedVisualizations] = await Promise.all([ + collectAsyncDefinitions(this.datasources), + collectAsyncDefinitions(this.visualizations), + ]); + + return await persistedStateToExpression(resolvedDatasources, resolvedVisualizations, doc); + } + public setup( core: CoreSetup, plugins: EditorFrameSetupPlugins @@ -74,6 +91,7 @@ export class EditorFrameService { coreHttp: coreStart.http, timefilter: deps.data.query.timefilter.timefilter, expressionRenderer: deps.expressions.ReactExpressionRenderer, + documentToExpression: this.documentToExpression.bind(this), indexPatternService: deps.data.indexPatterns, uiActions: deps.uiActions, }; @@ -88,7 +106,7 @@ export class EditorFrameService { this.datasources.push(datasource as Datasource); }, registerVisualization: (visualization) => { - this.visualizations.push(visualization as Visualization); + this.visualizations.push(visualization as Visualization); }, }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts index ca5fe706985f8..c487e31f5a973 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts @@ -23,3 +23,7 @@ export function loadInitialState() { }; return result; } + +const originalLoader = jest.requireActual('../loader'); + +export const extractReferences = originalLoader.extractReferences; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index dc3938ce436e5..0ba7b7df97853 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -128,12 +128,15 @@ const expectedIndexPatterns = { }, }; -function stateFromPersistedState( - persistedState: IndexPatternPersistedState -): IndexPatternPrivateState { +type IndexPatternBaseState = Omit< + IndexPatternPrivateState, + 'indexPatternRefs' | 'indexPatterns' | 'existingFields' | 'isFirstExistenceFetch' +>; + +function enrichBaseState(baseState: IndexPatternBaseState): IndexPatternPrivateState { return { - currentIndexPatternId: persistedState.currentIndexPatternId, - layers: persistedState.layers, + currentIndexPatternId: baseState.currentIndexPatternId, + layers: baseState.layers, indexPatterns: expectedIndexPatterns, indexPatternRefs: [], existingFields: {}, @@ -142,7 +145,10 @@ function stateFromPersistedState( } describe('IndexPattern Data Source', () => { - let persistedState: IndexPatternPersistedState; + let baseState: Omit< + IndexPatternPrivateState, + 'indexPatternRefs' | 'indexPatterns' | 'existingFields' | 'isFirstExistenceFetch' + >; let indexPatternDatasource: Datasource; beforeEach(() => { @@ -153,7 +159,7 @@ describe('IndexPattern Data Source', () => { charts: chartPluginMock.createSetupContract(), }); - persistedState = { + baseState = { currentIndexPatternId: '1', layers: { first: { @@ -224,9 +230,37 @@ describe('IndexPattern Data Source', () => { describe('#getPersistedState', () => { it('should persist from saved state', async () => { - const state = stateFromPersistedState(persistedState); + const state = enrichBaseState(baseState); - expect(indexPatternDatasource.getPersistableState(state)).toEqual(persistedState); + expect(indexPatternDatasource.getPersistableState(state)).toEqual({ + state: { + layers: { + first: { + columnOrder: ['col1'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + }, + }, + }, + }, + savedObjectReferences: [ + { name: 'indexpattern-datasource-current-indexpattern', type: 'index-pattern', id: '1' }, + { name: 'indexpattern-datasource-layer-first', type: 'index-pattern', id: '1' }, + ], + }); }); }); @@ -237,7 +271,7 @@ describe('IndexPattern Data Source', () => { }); it('should generate an expression for an aggregated query', async () => { - const queryPersistedState: IndexPatternPersistedState = { + const queryBaseState: IndexPatternBaseState = { currentIndexPatternId: '1', layers: { first: { @@ -266,7 +300,7 @@ describe('IndexPattern Data Source', () => { }, }; - const state = stateFromPersistedState(queryPersistedState); + const state = enrichBaseState(queryBaseState); expect(indexPatternDatasource.toExpression(state, 'first')).toMatchInlineSnapshot(` Object { @@ -311,7 +345,7 @@ describe('IndexPattern Data Source', () => { }); it('should put all time fields used in date_histograms to the esaggs timeFields parameter', async () => { - const queryPersistedState: IndexPatternPersistedState = { + const queryBaseState: IndexPatternBaseState = { currentIndexPatternId: '1', layers: { first: { @@ -350,14 +384,14 @@ describe('IndexPattern Data Source', () => { }, }; - const state = stateFromPersistedState(queryPersistedState); + const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']); }); it('should not put date fields used outside date_histograms to the esaggs timeFields parameter', async () => { - const queryPersistedState: IndexPatternPersistedState = { + const queryBaseState: IndexPatternBaseState = { currentIndexPatternId: '1', layers: { first: { @@ -386,7 +420,7 @@ describe('IndexPattern Data Source', () => { }, }; - const state = stateFromPersistedState(queryPersistedState); + const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']); @@ -489,55 +523,14 @@ describe('IndexPattern Data Source', () => { }); }); - describe('#getMetadata', () => { - it('should return the title of the index patterns', () => { - expect( - indexPatternDatasource.getMetaData({ - indexPatternRefs: [], - existingFields: {}, - isFirstExistenceFetch: false, - indexPatterns: expectedIndexPatterns, - layers: { - first: { - indexPatternId: '1', - columnOrder: [], - columns: {}, - }, - second: { - indexPatternId: '2', - columnOrder: [], - columns: {}, - }, - }, - currentIndexPatternId: '1', - }) - ).toEqual({ - filterableIndexPatterns: [ - { - id: '1', - title: 'my-fake-index-pattern', - }, - { - id: '2', - title: 'my-fake-restricted-pattern', - }, - ], - }); - }); - }); - describe('#getPublicAPI', () => { let publicAPI: DatasourcePublicAPI; beforeEach(async () => { - const initialState = stateFromPersistedState(persistedState); + const initialState = enrichBaseState(baseState); publicAPI = indexPatternDatasource.getPublicAPI({ state: initialState, layerId: 'first', - dateRange: { - fromDate: 'now-30d', - toDate: 'now', - }, }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 2fb8d7fe0e553..e2ca933504849 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -8,7 +8,7 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; -import { CoreStart } from 'kibana/public'; +import { CoreStart, SavedObjectReference } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { @@ -19,7 +19,12 @@ import { DatasourceLayerPanelProps, PublicAPIProps, } from '../types'; -import { loadInitialState, changeIndexPattern, changeLayerIndexPattern } from './loader'; +import { + loadInitialState, + changeIndexPattern, + changeLayerIndexPattern, + extractReferences, +} from './loader'; import { toExpression } from './to_expression'; import { IndexPatternDimensionTrigger, @@ -125,9 +130,13 @@ export function getIndexPatternDatasource({ const indexPatternDatasource: Datasource = { id: 'indexpattern', - async initialize(state?: IndexPatternPersistedState) { + async initialize( + persistedState?: IndexPatternPersistedState, + references?: SavedObjectReference[] + ) { return loadInitialState({ - state, + persistedState, + references, savedObjectsClient: await savedObjectsClient, defaultIndexPatternId: core.uiSettings.get('defaultIndex'), storage, @@ -135,8 +144,8 @@ export function getIndexPatternDatasource({ }); }, - getPersistableState({ currentIndexPatternId, layers }: IndexPatternPrivateState) { - return { currentIndexPatternId, layers }; + getPersistableState(state: IndexPatternPrivateState) { + return extractReferences(state); }, insertLayer(state: IndexPatternPrivateState, newLayerId: string) { @@ -183,19 +192,6 @@ export function getIndexPatternDatasource({ toExpression, - getMetaData(state: IndexPatternPrivateState) { - return { - filterableIndexPatterns: _.uniq( - Object.values(state.layers) - .map((layer) => layer.indexPatternId) - .map((indexPatternId) => ({ - id: indexPatternId, - title: state.indexPatterns[indexPatternId].title, - })) - ), - }; - }, - renderDataPanel( domElement: Element, props: DatasourceDataPanelProps diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index cfabcb4edcef7..d80bf779a5d17 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -12,6 +12,8 @@ import { changeIndexPattern, changeLayerIndexPattern, syncExistingFields, + extractReferences, + injectReferences, } from './loader'; import { IndexPatternsContract } from '../../../../../src/plugins/data/public'; import { @@ -378,10 +380,8 @@ describe('loader', () => { it('should initialize from saved state', async () => { const savedState: IndexPatternPersistedState = { - currentIndexPatternId: '2', layers: { layerb: { - indexPatternId: '2', columnOrder: ['col1', 'col2'], columns: { col1: { @@ -407,7 +407,12 @@ describe('loader', () => { }; const storage = createMockStorage({ indexPatternId: '1' }); const state = await loadInitialState({ - state: savedState, + persistedState: savedState, + references: [ + { name: 'indexpattern-datasource-current-indexpattern', id: '2', type: 'index-pattern' }, + { name: 'indexpattern-datasource-layer-layerb', id: '2', type: 'index-pattern' }, + { name: 'another-reference', id: 'c', type: 'index-pattern' }, + ], savedObjectsClient: mockClient(), indexPatternsService: mockIndexPatternsService(), storage, @@ -422,7 +427,7 @@ describe('loader', () => { indexPatterns: { '2': sampleIndexPatterns['2'], }, - layers: savedState.layers, + layers: { layerb: { ...savedState.layers.layerb, indexPatternId: '2' } }, }); expect(storage.set).toHaveBeenCalledWith('lens-settings', { @@ -431,6 +436,79 @@ describe('loader', () => { }); }); + describe('saved object references', () => { + const state: IndexPatternPrivateState = { + currentIndexPatternId: 'b', + indexPatternRefs: [], + indexPatterns: {}, + existingFields: {}, + layers: { + a: { + indexPatternId: 'id-index-pattern-a', + columnOrder: ['col1'], + columns: { + col1: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'myfield', + }, + }, + }, + b: { + indexPatternId: 'id-index-pattern-b', + columnOrder: ['col2'], + columns: { + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'myfield2', + }, + }, + }, + }, + isFirstExistenceFetch: false, + }; + + it('should create a reference for each layer and for current index pattern', () => { + const { savedObjectReferences } = extractReferences(state); + expect(savedObjectReferences).toMatchInlineSnapshot(` + Array [ + Object { + "id": "b", + "name": "indexpattern-datasource-current-indexpattern", + "type": "index-pattern", + }, + Object { + "id": "id-index-pattern-a", + "name": "indexpattern-datasource-layer-a", + "type": "index-pattern", + }, + Object { + "id": "id-index-pattern-b", + "name": "indexpattern-datasource-layer-b", + "type": "index-pattern", + }, + ] + `); + }); + + it('should restore layers', () => { + const { savedObjectReferences, state: persistedState } = extractReferences(state); + expect(injectReferences(persistedState, savedObjectReferences).layers).toEqual(state.layers); + }); + + it('should restore current index pattern', () => { + const { savedObjectReferences, state: persistedState } = extractReferences(state); + expect(injectReferences(persistedState, savedObjectReferences).currentIndexPatternId).toEqual( + state.currentIndexPatternId + ); + }); + }); + describe('changeIndexPattern', () => { it('loads the index pattern and then sets it as current', async () => { const setState = jest.fn(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 9c4a19e58a052..24906790a9fc9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -6,7 +6,7 @@ import _ from 'lodash'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { SavedObjectsClientContract, HttpSetup } from 'kibana/public'; +import { SavedObjectsClientContract, HttpSetup, SavedObjectReference } from 'kibana/public'; import { StateSetter } from '../types'; import { IndexPattern, @@ -14,6 +14,7 @@ import { IndexPatternPersistedState, IndexPatternPrivateState, IndexPatternField, + IndexPatternLayer, } from './types'; import { updateLayerIndexPattern } from './state_helpers'; import { DateRange, ExistingFields } from '../../common/types'; @@ -115,14 +116,58 @@ const setLastUsedIndexPatternId = (storage: IStorageWrapper, value: string) => { writeToStorage(storage, 'indexPatternId', value); }; +const CURRENT_PATTERN_REFERENCE_NAME = 'indexpattern-datasource-current-indexpattern'; +function getLayerReferenceName(layerId: string) { + return `indexpattern-datasource-layer-${layerId}`; +} + +export function extractReferences({ currentIndexPatternId, layers }: IndexPatternPrivateState) { + const savedObjectReferences: SavedObjectReference[] = []; + savedObjectReferences.push({ + type: 'index-pattern', + id: currentIndexPatternId, + name: CURRENT_PATTERN_REFERENCE_NAME, + }); + const persistableLayers: Record> = {}; + Object.entries(layers).forEach(([layerId, { indexPatternId, ...persistableLayer }]) => { + savedObjectReferences.push({ + type: 'index-pattern', + id: indexPatternId, + name: getLayerReferenceName(layerId), + }); + persistableLayers[layerId] = persistableLayer; + }); + return { savedObjectReferences, state: { layers: persistableLayers } }; +} + +export function injectReferences( + state: IndexPatternPersistedState, + references: SavedObjectReference[] +) { + const layers: Record = {}; + Object.entries(state.layers).forEach(([layerId, persistedLayer]) => { + layers[layerId] = { + ...persistedLayer, + indexPatternId: references.find(({ name }) => name === getLayerReferenceName(layerId))!.id, + }; + }); + return { + currentIndexPatternId: references.find(({ name }) => name === CURRENT_PATTERN_REFERENCE_NAME)! + .id, + layers, + }; +} + export async function loadInitialState({ - state, + persistedState, + references, savedObjectsClient, defaultIndexPatternId, storage, indexPatternsService, }: { - state?: IndexPatternPersistedState; + persistedState?: IndexPatternPersistedState; + references?: SavedObjectReference[]; savedObjectsClient: SavedObjectsClient; defaultIndexPatternId?: string; storage: IStorageWrapper; @@ -131,6 +176,9 @@ export async function loadInitialState({ const indexPatternRefs = await loadIndexPatternRefs(savedObjectsClient); const lastUsedIndexPatternId = getLastUsedIndexPatternId(storage, indexPatternRefs); + const state = + persistedState && references ? injectReferences(persistedState, references) : undefined; + const requiredPatterns = _.uniq( state ? Object.values(state.layers) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 8d0e82b176aa9..95cc47e68f8a1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -40,11 +40,12 @@ export interface IndexPatternLayer { } export interface IndexPatternPersistedState { - currentIndexPatternId: string; - layers: Record; + layers: Record>; } -export type IndexPatternPrivateState = IndexPatternPersistedState & { +export interface IndexPatternPrivateState { + currentIndexPatternId: string; + layers: Record; indexPatternRefs: IndexPatternRef[]; indexPatterns: Record; @@ -54,7 +55,7 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & { existingFields: Record>; isFirstExistenceFetch: boolean; existenceFetchFailed?: boolean; -}; +} export interface IndexPatternRef { id: string; diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_visualization.test.ts b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.test.ts index 62f47a21c85b0..f3c9a725ee2e2 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_visualization.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.test.ts @@ -66,12 +66,6 @@ describe('metric_visualization', () => { }); }); - describe('#getPersistableState', () => { - it('persists the state as given', () => { - expect(metricVisualization.getPersistableState(exampleState())).toEqual(exampleState()); - }); - }); - describe('#getConfiguration', () => { it('can add a metric when there is no accessor', () => { expect( @@ -168,7 +162,8 @@ describe('metric_visualization', () => { datasourceLayers: { l1: datasource }, }; - expect(metricVisualization.toExpression(exampleState(), frame)).toMatchInlineSnapshot(` + expect(metricVisualization.toExpression(exampleState(), frame.datasourceLayers)) + .toMatchInlineSnapshot(` Object { "chain": Array [ Object { diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx index e565d2fa8b293..5f1ce5334dd36 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx @@ -7,20 +7,20 @@ import { i18n } from '@kbn/i18n'; import { Ast } from '@kbn/interpreter/target/common'; import { getSuggestions } from './metric_suggestions'; -import { Visualization, FramePublicAPI, OperationMetadata } from '../types'; -import { State, PersistableState } from './types'; +import { Visualization, OperationMetadata, DatasourcePublicAPI } from '../types'; +import { State } from './types'; import chartMetricSVG from '../assets/chart_metric.svg'; const toExpression = ( state: State, - frame: FramePublicAPI, + datasourceLayers: Record, mode: 'reduced' | 'full' = 'full' ): Ast | null => { if (!state.accessor) { return null; } - const [datasource] = Object.values(frame.datasourceLayers); + const [datasource] = Object.values(datasourceLayers); const operation = datasource && datasource.getOperationForColumnId(state.accessor); return { @@ -39,7 +39,7 @@ const toExpression = ( }; }; -export const metricVisualization: Visualization = { +export const metricVisualization: Visualization = { id: 'lnsMetric', visualizationTypes: [ @@ -88,8 +88,6 @@ export const metricVisualization: Visualization = { ); }, - getPersistableState: (state) => state, - getConfiguration(props) { return { groups: [ @@ -106,8 +104,8 @@ export const metricVisualization: Visualization = { }, toExpression, - toPreviewExpression: (state: State, frame: FramePublicAPI) => - toExpression(state, frame, 'reduced'), + toPreviewExpression: (state, datasourceLayers) => + toExpression(state, datasourceLayers, 'reduced'), setDimension({ prevState, columnId }) { return { ...prevState, accessor: columnId }; diff --git a/x-pack/plugins/lens/public/metric_visualization/types.ts b/x-pack/plugins/lens/public/metric_visualization/types.ts index 53fc103934255..86a781716b345 100644 --- a/x-pack/plugins/lens/public/metric_visualization/types.ts +++ b/x-pack/plugins/lens/public/metric_visualization/types.ts @@ -13,5 +13,3 @@ export interface MetricConfig extends State { title: string; mode: 'reduced' | 'full'; } - -export type PersistableState = State; diff --git a/x-pack/plugins/lens/public/persistence/filter_references.test.ts b/x-pack/plugins/lens/public/persistence/filter_references.test.ts new file mode 100644 index 0000000000000..23c0cd1d11f1b --- /dev/null +++ b/x-pack/plugins/lens/public/persistence/filter_references.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Filter } from 'src/plugins/data/public'; +import { extractFilterReferences, injectFilterReferences } from './filter_references'; +import { FilterStateStore } from 'src/plugins/data/common'; + +describe('filter saved object references', () => { + const filters: Filter[] = [ + { + $state: { store: FilterStateStore.APP_STATE }, + meta: { + alias: null, + disabled: false, + index: '90943e30-9a47-11e8-b64d-95841ca0b247', + key: 'geo.src', + negate: true, + params: { query: 'CN' }, + type: 'phrase', + }, + query: { match_phrase: { 'geo.src': 'CN' } }, + }, + { + $state: { store: FilterStateStore.APP_STATE }, + meta: { + alias: null, + disabled: false, + index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + key: 'geoip.country_iso_code', + negate: true, + params: { query: 'US' }, + type: 'phrase', + }, + query: { match_phrase: { 'geoip.country_iso_code': 'US' } }, + }, + ]; + + it('should create two index-pattern references', () => { + const { references } = extractFilterReferences(filters); + expect(references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "90943e30-9a47-11e8-b64d-95841ca0b247", + "name": "filter-index-pattern-0", + "type": "index-pattern", + }, + Object { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "filter-index-pattern-1", + "type": "index-pattern", + }, + ] + `); + }); + + it('should restore the same filter after extracting and injecting', () => { + const { persistableFilters, references } = extractFilterReferences(filters); + expect(injectFilterReferences(persistableFilters, references)).toEqual(filters); + }); + + it('should ignore other references', () => { + const { persistableFilters, references } = extractFilterReferences(filters); + expect( + injectFilterReferences(persistableFilters, [ + { type: 'index-pattern', id: '1234', name: 'some other index pattern' }, + ...references, + ]) + ).toEqual(filters); + }); + + it('should inject other ids if references change', () => { + const { persistableFilters, references } = extractFilterReferences(filters); + + expect( + injectFilterReferences( + persistableFilters, + references.map((reference, index) => ({ ...reference, id: `overwritten-id-${index}` })) + ) + ).toEqual([ + { + ...filters[0], + meta: { + ...filters[0].meta, + index: 'overwritten-id-0', + }, + }, + { + ...filters[1], + meta: { + ...filters[1].meta, + index: 'overwritten-id-1', + }, + }, + ]); + }); +}); diff --git a/x-pack/plugins/lens/public/persistence/filter_references.ts b/x-pack/plugins/lens/public/persistence/filter_references.ts new file mode 100644 index 0000000000000..47564e510ce9c --- /dev/null +++ b/x-pack/plugins/lens/public/persistence/filter_references.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Filter } from 'src/plugins/data/public'; +import { SavedObjectReference } from 'kibana/public'; +import { PersistableFilter } from '../../common'; + +export function extractFilterReferences( + filters: Filter[] +): { persistableFilters: PersistableFilter[]; references: SavedObjectReference[] } { + const references: SavedObjectReference[] = []; + const persistableFilters = filters.map((filterRow, i) => { + if (!filterRow.meta || !filterRow.meta.index) { + return filterRow; + } + const refName = `filter-index-pattern-${i}`; + references.push({ + name: refName, + type: 'index-pattern', + id: filterRow.meta.index, + }); + return { + ...filterRow, + meta: { + ...filterRow.meta, + indexRefName: refName, + index: undefined, + }, + }; + }); + + return { persistableFilters, references }; +} + +export function injectFilterReferences( + filters: PersistableFilter[], + references: SavedObjectReference[] +) { + return filters.map((filterRow) => { + if (!filterRow.meta || !filterRow.meta.indexRefName) { + return filterRow as Filter; + } + const { indexRefName, ...metaRest } = filterRow.meta; + const reference = references.find((ref) => ref.name === indexRefName); + if (!reference) { + throw new Error(`Could not find reference for ${indexRefName}`); + } + return { + ...filterRow, + meta: { ...metaRest, index: reference.id }, + }; + }); +} diff --git a/x-pack/plugins/lens/public/persistence/index.ts b/x-pack/plugins/lens/public/persistence/index.ts index 1f823ff75c8c6..464bd46790422 100644 --- a/x-pack/plugins/lens/public/persistence/index.ts +++ b/x-pack/plugins/lens/public/persistence/index.ts @@ -5,3 +5,4 @@ */ export * from './saved_object_store'; +export * from './filter_references'; diff --git a/x-pack/plugins/lens/public/persistence/saved_object_store.test.ts b/x-pack/plugins/lens/public/persistence/saved_object_store.test.ts index f8f8d889233a7..ba7c0ee6ae786 100644 --- a/x-pack/plugins/lens/public/persistence/saved_object_store.test.ts +++ b/x-pack/plugins/lens/public/persistence/saved_object_store.test.ts @@ -30,11 +30,8 @@ describe('LensStore', () => { title: 'Hello', description: 'My doc', visualizationType: 'bar', - expression: '', + references: [], state: { - datasourceMetaData: { - filterableIndexPatterns: [], - }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, }, @@ -49,11 +46,8 @@ describe('LensStore', () => { title: 'Hello', description: 'My doc', visualizationType: 'bar', - expression: '', + references: [], state: { - datasourceMetaData: { - filterableIndexPatterns: [], - }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, }, @@ -64,21 +58,25 @@ describe('LensStore', () => { }); expect(client.create).toHaveBeenCalledTimes(1); - expect(client.create).toHaveBeenCalledWith('lens', { - title: 'Hello', - description: 'My doc', - visualizationType: 'bar', - expression: '', - state: { - datasourceMetaData: { filterableIndexPatterns: [] }, - datasourceStates: { - indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, + expect(client.create).toHaveBeenCalledWith( + 'lens', + { + title: 'Hello', + description: 'My doc', + visualizationType: 'bar', + state: { + datasourceStates: { + indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, + }, + visualization: { x: 'foo', y: 'baz' }, + query: { query: '', language: 'lucene' }, + filters: [], }, - visualization: { x: 'foo', y: 'baz' }, - query: { query: '', language: 'lucene' }, - filters: [], }, - }); + { + references: [], + } + ); }); test('updates and returns a visualization document', async () => { @@ -87,9 +85,8 @@ describe('LensStore', () => { id: 'Gandalf', title: 'Even the very wise cannot see all ends.', visualizationType: 'line', - expression: '', + references: [], state: { - datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, visualization: { gear: ['staff', 'pointy hat'] }, query: { query: '', language: 'lucene' }, @@ -101,9 +98,8 @@ describe('LensStore', () => { id: 'Gandalf', title: 'Even the very wise cannot see all ends.', visualizationType: 'line', - expression: '', + references: [], state: { - datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, visualization: { gear: ['staff', 'pointy hat'] }, query: { query: '', language: 'lucene' }, @@ -116,22 +112,21 @@ describe('LensStore', () => { { type: 'lens', id: 'Gandalf', + references: [], attributes: { title: null, visualizationType: null, - expression: null, state: null, }, }, { type: 'lens', id: 'Gandalf', + references: [], attributes: { title: 'Even the very wise cannot see all ends.', visualizationType: 'line', - expression: '', state: { - datasourceMetaData: { filterableIndexPatterns: [] }, datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, visualization: { gear: ['staff', 'pointy hat'] }, query: { query: '', language: 'lucene' }, diff --git a/x-pack/plugins/lens/public/persistence/saved_object_store.ts b/x-pack/plugins/lens/public/persistence/saved_object_store.ts index 59ead53956a8d..e4609213ec792 100644 --- a/x-pack/plugins/lens/public/persistence/saved_object_store.ts +++ b/x-pack/plugins/lens/public/persistence/saved_object_store.ts @@ -4,8 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectAttributes, SavedObjectsClientContract } from 'kibana/public'; -import { Query, Filter } from '../../../../../src/plugins/data/public'; +import { + SavedObjectAttributes, + SavedObjectsClientContract, + SavedObjectReference, +} from 'kibana/public'; +import { Query } from '../../../../../src/plugins/data/public'; +import { PersistableFilter } from '../../common'; export interface Document { id?: string; @@ -13,16 +18,13 @@ export interface Document { visualizationType: string | null; title: string; description?: string; - expression: string | null; state: { - datasourceMetaData: { - filterableIndexPatterns: Array<{ id: string; title: string }>; - }; datasourceStates: Record; visualization: unknown; query: Query; - filters: Filter[]; + filters: PersistableFilter[]; }; + references: SavedObjectReference[]; } export const DOC_TYPE = 'lens'; @@ -45,14 +47,16 @@ export class SavedObjectIndexStore implements SavedObjectStore { } async save(vis: Document) { - const { id, type, ...rest } = vis; + const { id, type, references, ...rest } = vis; // TODO: SavedObjectAttributes should support this kind of object, // remove this workaround when SavedObjectAttributes is updated. const attributes = (rest as unknown) as SavedObjectAttributes; const result = await (id - ? this.safeUpdate(id, attributes) - : this.client.create(DOC_TYPE, attributes)); + ? this.safeUpdate(id, attributes, references) + : this.client.create(DOC_TYPE, attributes, { + references, + })); return { ...vis, id: result.id }; } @@ -63,21 +67,25 @@ export class SavedObjectIndexStore implements SavedObjectStore { // deleted subtrees make it back into the object after a load. // This function fixes this by doing two updates - one to empty out the document setting // every key to null, and a second one to load the new content. - private async safeUpdate(id: string, attributes: SavedObjectAttributes) { + private async safeUpdate( + id: string, + attributes: SavedObjectAttributes, + references: SavedObjectReference[] + ) { const resetAttributes: SavedObjectAttributes = {}; Object.keys(attributes).forEach((key) => { resetAttributes[key] = null; }); return ( await this.client.bulkUpdate([ - { type: DOC_TYPE, id, attributes: resetAttributes }, - { type: DOC_TYPE, id, attributes }, + { type: DOC_TYPE, id, attributes: resetAttributes, references }, + { type: DOC_TYPE, id, attributes, references }, ]) ).savedObjects[1]; } async load(id: string): Promise { - const { type, attributes, error } = await this.client.get(DOC_TYPE, id); + const { type, attributes, references, error } = await this.client.get(DOC_TYPE, id); if (error) { throw error; @@ -85,6 +93,7 @@ export class SavedObjectIndexStore implements SavedObjectStore { return { ...(attributes as SavedObjectAttributes), + references, id, type, } as Document; diff --git a/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx index 5a68516db6aa3..855bacd4f794c 100644 --- a/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx @@ -31,7 +31,7 @@ const bucketedOperations = (op: OperationMetadata) => op.isBucketed; const numberMetricOperations = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; -export const pieVisualization: Visualization = { +export const pieVisualization: Visualization = { id: 'lnsPie', visualizationTypes: [ @@ -91,8 +91,6 @@ export const pieVisualization: Visualization state, - getSuggestions: suggestions, getConfiguration({ state, frame, layerId }) { diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts index fbc47e8bfb00f..f36b9efb930a9 100644 --- a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -5,21 +5,24 @@ */ import { Ast } from '@kbn/interpreter/common'; -import { FramePublicAPI, Operation } from '../types'; +import { Operation, DatasourcePublicAPI } from '../types'; import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PieVisualizationState } from './types'; -export function toExpression(state: PieVisualizationState, frame: FramePublicAPI) { - return expressionHelper(state, frame, false); +export function toExpression( + state: PieVisualizationState, + datasourceLayers: Record +) { + return expressionHelper(state, datasourceLayers, false); } function expressionHelper( state: PieVisualizationState, - frame: FramePublicAPI, + datasourceLayers: Record, isPreview: boolean ): Ast | null { const layer = state.layers[0]; - const datasource = frame.datasourceLayers[layer.layerId]; + const datasource = datasourceLayers[layer.layerId]; const operations = layer.groups .map((columnId) => ({ columnId, operation: datasource.getOperationForColumnId(columnId) })) .filter((o): o is { columnId: string; operation: Operation } => !!o.operation); @@ -50,6 +53,9 @@ function expressionHelper( }; } -export function toPreviewExpression(state: PieVisualizationState, frame: FramePublicAPI) { - return expressionHelper(state, frame, true); +export function toPreviewExpression( + state: PieVisualizationState, + datasourceLayers: Record +) { + return expressionHelper(state, datasourceLayers, true); } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index c7bda65cd1327..20f2ce6c56774 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -7,6 +7,7 @@ import { Ast } from '@kbn/interpreter/common'; import { IconType } from '@elastic/eui/src/components/icon/icon'; import { CoreSetup } from 'kibana/public'; +import { SavedObjectReference } from 'kibana/public'; import { ExpressionRendererEvent, IInterpreterRenderHandlers, @@ -30,7 +31,6 @@ export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; export interface PublicAPIProps { state: T; layerId: string; - dateRange: DateRange; } export interface EditorFrameProps { @@ -44,8 +44,9 @@ export interface EditorFrameProps { // Frame loader (app or embeddable) is expected to call this when it loads and updates // This should be replaced with a top-down state onChange: (newState: { - filterableIndexPatterns: DatasourceMetaData['filterableIndexPatterns']; + filterableIndexPatterns: string[]; doc: Document; + isSaveable: boolean; }) => void; showNoDataPopover: () => void; } @@ -57,9 +58,7 @@ export interface EditorFrameInstance { export interface EditorFrameSetup { // generic type on the API functions to pull the "unknown vs. specific type" error into the implementation registerDatasource: (datasource: Datasource | Promise>) => void; - registerVisualization: ( - visualization: Visualization | Promise> - ) => void; + registerVisualization: (visualization: Visualization | Promise>) => void; } export interface EditorFrameStart { @@ -131,10 +130,6 @@ export interface DatasourceSuggestion { keptLayerIds: string[]; } -export interface DatasourceMetaData { - filterableIndexPatterns: Array<{ id: string; title: string }>; -} - export type StateSetter = (newState: T | ((prevState: T) => T)) => void; /** @@ -146,10 +141,10 @@ export interface Datasource { // For initializing, either from an empty state or from persisted state // Because this will be called at runtime, state might have a type of `any` and // datasources should validate their arguments - initialize: (state?: P) => Promise; + initialize: (state?: P, savedObjectReferences?: SavedObjectReference[]) => Promise; // Given the current state, which parts should be saved? - getPersistableState: (state: T) => P; + getPersistableState: (state: T) => { state: P; savedObjectReferences: SavedObjectReference[] }; insertLayer: (state: T, newLayerId: string) => T; removeLayer: (state: T, layerId: string) => T; @@ -166,8 +161,6 @@ export interface Datasource { toExpression: (state: T, layerId: string) => Ast | string | null; - getMetaData: (state: T) => DatasourceMetaData; - getDatasourceSuggestionsForField: (state: T, field: unknown) => Array>; getDatasourceSuggestionsFromCurrentState: (state: T) => Array>; @@ -408,7 +401,7 @@ export interface VisualizationType { label: string; } -export interface Visualization { +export interface Visualization { /** Plugin ID, such as "lnsXY" */ id: string; @@ -418,11 +411,7 @@ export interface Visualization { * - Loadingn from a saved visualization * - When using suggestions, the suggested state is passed in */ - initialize: (frame: FramePublicAPI, state?: P) => T; - /** - * Can remove any state that should not be persisted to saved object, such as UI state - */ - getPersistableState: (state: T) => P; + initialize: (frame: FramePublicAPI, state?: T) => T; /** * Visualizations must provide at least one type for the chart switcher, @@ -504,12 +493,18 @@ export interface Visualization { */ getSuggestions: (context: SuggestionRequest) => Array>; - toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null; + toExpression: ( + state: T, + datasourceLayers: Record + ) => Ast | string | null; /** * Expression to render a preview version of the chart in very constrained space. * If there is no expression provided, the preview icon is used. */ - toPreviewExpression?: (state: T, frame: FramePublicAPI) => Ast | string | null; + toPreviewExpression?: ( + state: T, + datasourceLayers: Record + ) => Ast | string | null; } export interface LensFilterEvent { diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index 876d1141740e1..f579085646f6f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -53,7 +53,7 @@ describe('#toExpression', () => { }, ], }, - frame + frame.datasourceLayers ) ).toMatchSnapshot(); }); @@ -74,7 +74,7 @@ describe('#toExpression', () => { }, ], }, - frame + frame.datasourceLayers ) as Ast).chain[0].arguments.fittingFunction[0] ).toEqual('None'); }); @@ -94,7 +94,7 @@ describe('#toExpression', () => { }, ], }, - frame + frame.datasourceLayers ) as Ast; expect(expression.chain[0].arguments.showXAxisTitle[0]).toBe(true); expect(expression.chain[0].arguments.showYAxisTitle[0]).toBe(true); @@ -116,7 +116,7 @@ describe('#toExpression', () => { }, ], }, - frame + frame.datasourceLayers ) ).toBeNull(); }); @@ -137,7 +137,7 @@ describe('#toExpression', () => { }, ], }, - frame + frame.datasourceLayers ) ).toBeNull(); }); @@ -157,7 +157,7 @@ describe('#toExpression', () => { }, ], }, - frame + frame.datasourceLayers )! as Ast; expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('b'); @@ -191,7 +191,7 @@ describe('#toExpression', () => { }, ], }, - frame + frame.datasourceLayers ) as Ast; expect( (expression.chain[0].arguments.tickLabelsVisibilitySettings[0] as Ast).chain[0].arguments @@ -216,7 +216,7 @@ describe('#toExpression', () => { }, ], }, - frame + frame.datasourceLayers ) as Ast; expect( (expression.chain[0].arguments.gridlinesVisibilitySettings[0] as Ast).chain[0].arguments diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 9b9c159af265e..cd32d4f94c3e5 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -7,13 +7,16 @@ import { Ast } from '@kbn/interpreter/common'; import { ScaleType } from '@elastic/charts'; import { State, LayerConfig } from './types'; -import { FramePublicAPI, OperationMetadata } from '../types'; +import { OperationMetadata, DatasourcePublicAPI } from '../types'; interface ValidLayer extends LayerConfig { xAccessor: NonNullable; } -export const toExpression = (state: State, frame: FramePublicAPI): Ast | null => { +export const toExpression = ( + state: State, + datasourceLayers: Record +): Ast | null => { if (!state || !state.layers.length) { return null; } @@ -21,19 +24,20 @@ export const toExpression = (state: State, frame: FramePublicAPI): Ast | null => const metadata: Record> = {}; state.layers.forEach((layer) => { metadata[layer.layerId] = {}; - const datasource = frame.datasourceLayers[layer.layerId]; + const datasource = datasourceLayers[layer.layerId]; datasource.getTableSpec().forEach((column) => { - const operation = frame.datasourceLayers[layer.layerId].getOperationForColumnId( - column.columnId - ); + const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId); metadata[layer.layerId][column.columnId] = operation; }); }); - return buildExpression(state, metadata, frame); + return buildExpression(state, metadata, datasourceLayers); }; -export function toPreviewExpression(state: State, frame: FramePublicAPI) { +export function toPreviewExpression( + state: State, + datasourceLayers: Record +) { return toExpression( { ...state, @@ -44,7 +48,7 @@ export function toPreviewExpression(state: State, frame: FramePublicAPI) { isVisible: false, }, }, - frame + datasourceLayers ); } @@ -77,7 +81,7 @@ export function getScaleType(metadata: OperationMetadata | null, defaultScale: S export const buildExpression = ( state: State, metadata: Record>, - frame?: FramePublicAPI + datasourceLayers?: Record ): Ast | null => { const validLayers = state.layers.filter((layer): layer is ValidLayer => Boolean(layer.xAccessor && layer.accessors.length) @@ -149,8 +153,8 @@ export const buildExpression = ( layers: validLayers.map((layer) => { const columnToLabel: Record = {}; - if (frame) { - const datasource = frame.datasourceLayers[layer.layerId]; + if (datasourceLayers) { + const datasource = datasourceLayers[layer.layerId]; layer.accessors .concat(layer.splitAccessor ? [layer.splitAccessor] : []) .forEach((accessor) => { @@ -162,8 +166,8 @@ export const buildExpression = ( } const xAxisOperation = - frame && - frame.datasourceLayers[layer.layerId].getOperationForColumnId(layer.xAccessor); + datasourceLayers && + datasourceLayers[layer.layerId].getOperationForColumnId(layer.xAccessor); const isHistogramDimension = Boolean( xAxisOperation && diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index ab689ceb183be..2739ffe42f13f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -339,7 +339,6 @@ export interface XYState { } export type State = XYState; -export type PersistableState = XYState; export const visualizationTypes: VisualizationType[] = [ { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts index 0a8e8bbe0c46f..53f7a23dcae98 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts @@ -157,12 +157,6 @@ describe('xy_visualization', () => { }); }); - describe('#getPersistableState', () => { - it('persists the state as given', () => { - expect(xyVisualization.getPersistableState(exampleState())).toEqual(exampleState()); - }); - }); - describe('#removeLayer', () => { it('removes the specified layer', () => { const prevState: State = { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx index f321e0962caa8..8c551c575764e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx @@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import { getSuggestions } from './xy_suggestions'; import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; import { Visualization, OperationMetadata, VisualizationType } from '../types'; -import { State, PersistableState, SeriesType, visualizationTypes, LayerConfig } from './types'; +import { State, SeriesType, visualizationTypes, LayerConfig } from './types'; import chartBarStackedSVG from '../assets/chart_bar_stacked.svg'; import chartMixedSVG from '../assets/chart_mixed_xy.svg'; import { isHorizontalChart } from './state_helpers'; @@ -74,7 +74,7 @@ function getDescription(state?: State) { }; } -export const xyVisualization: Visualization = { +export const xyVisualization: Visualization = { id: 'lnsXY', visualizationTypes, @@ -159,8 +159,6 @@ export const xyVisualization: Visualization = { ); }, - getPersistableState: (state) => state, - getConfiguration(props) { const layer = props.state.layers.find((l) => l.layerId === props.layerId)!; return { diff --git a/x-pack/plugins/lens/server/__snapshots__/migrations.test.ts.snap b/x-pack/plugins/lens/server/__snapshots__/migrations.test.ts.snap new file mode 100644 index 0000000000000..4979438dbd3d0 --- /dev/null +++ b/x-pack/plugins/lens/server/__snapshots__/migrations.test.ts.snap @@ -0,0 +1,188 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Lens migrations 7.10.0 references should produce a valid document 1`] = ` +Object { + "attributes": Object { + "state": Object { + "datasourceStates": Object { + "indexpattern": Object { + "layers": Object { + "3b7791e9-326e-40d5-a787-b7594e48d906": Object { + "columnOrder": Array [ + "77d8383e-f66e-471e-ae50-c427feedb5ba", + "a5c1b82d-51de-4448-a99d-6391432c3a03", + ], + "columns": Object { + "77d8383e-f66e-471e-ae50-c427feedb5ba": Object { + "dataType": "string", + "isBucketed": true, + "label": "Top values of geoip.country_iso_code", + "operationType": "terms", + "params": Object { + "orderBy": Object { + "columnId": "a5c1b82d-51de-4448-a99d-6391432c3a03", + "type": "column", + }, + "orderDirection": "desc", + "size": 5, + }, + "scale": "ordinal", + "sourceField": "geoip.country_iso_code", + }, + "a5c1b82d-51de-4448-a99d-6391432c3a03": Object { + "dataType": "number", + "isBucketed": false, + "label": "Count of records", + "operationType": "count", + "scale": "ratio", + "sourceField": "Records", + }, + }, + }, + "9a27f85d-35a9-4246-81b2-48e7ee9b0707": Object { + "columnOrder": Array [ + "96352896-c508-4fca-90d8-66e9ebfce621", + "4ce9b4c7-2ebf-4d48-8669-0ea69d973353", + ], + "columns": Object { + "4ce9b4c7-2ebf-4d48-8669-0ea69d973353": Object { + "dataType": "number", + "isBucketed": false, + "label": "Count of records", + "operationType": "count", + "scale": "ratio", + "sourceField": "Records", + }, + "96352896-c508-4fca-90d8-66e9ebfce621": Object { + "dataType": "string", + "isBucketed": true, + "label": "Top values of geo.src", + "operationType": "terms", + "params": Object { + "orderBy": Object { + "columnId": "4ce9b4c7-2ebf-4d48-8669-0ea69d973353", + "type": "column", + }, + "orderDirection": "desc", + "size": 5, + }, + "scale": "ordinal", + "sourceField": "geo.src", + }, + }, + }, + }, + }, + }, + "filters": Array [ + Object { + "$state": Object { + "store": "appState", + }, + "meta": Object { + "alias": null, + "disabled": false, + "index": undefined, + "indexRefName": "filter-index-pattern-0", + "key": "geo.src", + "negate": true, + "params": Object { + "query": "CN", + }, + "type": "phrase", + }, + "query": Object { + "match_phrase": Object { + "geo.src": "CN", + }, + }, + }, + Object { + "$state": Object { + "store": "appState", + }, + "meta": Object { + "alias": null, + "disabled": false, + "index": undefined, + "indexRefName": "filter-index-pattern-1", + "key": "geoip.country_iso_code", + "negate": true, + "params": Object { + "query": "US", + }, + "type": "phrase", + }, + "query": Object { + "match_phrase": Object { + "geoip.country_iso_code": "US", + }, + }, + }, + ], + "query": Object { + "language": "kuery", + "query": "NOT bytes > 5000", + }, + "visualization": Object { + "fittingFunction": "None", + "layers": Array [ + Object { + "accessors": Array [ + "4ce9b4c7-2ebf-4d48-8669-0ea69d973353", + ], + "layerId": "9a27f85d-35a9-4246-81b2-48e7ee9b0707", + "position": "top", + "seriesType": "bar", + "showGridlines": false, + "xAccessor": "96352896-c508-4fca-90d8-66e9ebfce621", + }, + Object { + "accessors": Array [ + "a5c1b82d-51de-4448-a99d-6391432c3a03", + ], + "layerId": "3b7791e9-326e-40d5-a787-b7594e48d906", + "seriesType": "bar", + "xAccessor": "77d8383e-f66e-471e-ae50-c427feedb5ba", + }, + ], + "legend": Object { + "isVisible": true, + "position": "right", + }, + "preferredSeriesType": "bar", + }, + }, + "title": "mylens", + "visualizationType": "lnsXY", + }, + "references": Array [ + Object { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "indexpattern-datasource-current-indexpattern", + "type": "index-pattern", + }, + Object { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "indexpattern-datasource-layer-3b7791e9-326e-40d5-a787-b7594e48d906", + "type": "index-pattern", + }, + Object { + "id": "90943e30-9a47-11e8-b64d-95841ca0b247", + "name": "indexpattern-datasource-layer-9a27f85d-35a9-4246-81b2-48e7ee9b0707", + "type": "index-pattern", + }, + Object { + "id": "90943e30-9a47-11e8-b64d-95841ca0b247", + "name": "filter-index-pattern-0", + "type": "index-pattern", + }, + Object { + "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", + "name": "filter-index-pattern-1", + "type": "index-pattern", + }, + ], + "type": "lens", +} +`; diff --git a/x-pack/plugins/lens/server/migrations.test.ts b/x-pack/plugins/lens/server/migrations.test.ts index 0541d9636577b..676494dcab619 100644 --- a/x-pack/plugins/lens/server/migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations.test.ts @@ -278,4 +278,233 @@ describe('Lens migrations', () => { expect(result).toEqual(input); }); }); + + describe('7.10.0 references', () => { + const context = {} as SavedObjectMigrationContext; + + const example = { + attributes: { + description: '', + expression: + 'kibana\n| kibana_context query="{\\"query\\":\\"NOT bytes > 5000\\",\\"language\\":\\"kuery\\"}" \n filters="[{\\"meta\\":{\\"index\\":\\"90943e30-9a47-11e8-b64d-95841ca0b247\\",\\"alias\\":null,\\"negate\\":true,\\"disabled\\":false,\\"type\\":\\"phrase\\",\\"key\\":\\"geo.src\\",\\"params\\":{\\"query\\":\\"CN\\"}},\\"query\\":{\\"match_phrase\\":{\\"geo.src\\":\\"CN\\"}},\\"$state\\":{\\"store\\":\\"appState\\"}},{\\"meta\\":{\\"index\\":\\"ff959d40-b880-11e8-a6d9-e546fe2bba5f\\",\\"alias\\":null,\\"negate\\":true,\\"disabled\\":false,\\"type\\":\\"phrase\\",\\"key\\":\\"geoip.country_iso_code\\",\\"params\\":{\\"query\\":\\"US\\"}},\\"query\\":{\\"match_phrase\\":{\\"geoip.country_iso_code\\":\\"US\\"}},\\"$state\\":{\\"store\\":\\"appState\\"}}]"\n| lens_merge_tables layerIds="9a27f85d-35a9-4246-81b2-48e7ee9b0707"\n layerIds="3b7791e9-326e-40d5-a787-b7594e48d906" \n tables={esaggs index="90943e30-9a47-11e8-b64d-95841ca0b247" metricsAtAllLevels=true partialRows=true includeFormatHints=true aggConfigs="[{\\"id\\":\\"96352896-c508-4fca-90d8-66e9ebfce621\\",\\"enabled\\":true,\\"type\\":\\"terms\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"geo.src\\",\\"orderBy\\":\\"4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\",\\"order\\":\\"desc\\",\\"size\\":5,\\"otherBucket\\":false,\\"otherBucketLabel\\":\\"Other\\",\\"missingBucket\\":false,\\"missingBucketLabel\\":\\"Missing\\"}},{\\"id\\":\\"4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}}]" | lens_rename_columns idMap="{\\"col-0-96352896-c508-4fca-90d8-66e9ebfce621\\":{\\"label\\":\\"Top values of geo.src\\",\\"dataType\\":\\"string\\",\\"operationType\\":\\"terms\\",\\"scale\\":\\"ordinal\\",\\"sourceField\\":\\"geo.src\\",\\"isBucketed\\":true,\\"params\\":{\\"size\\":5,\\"orderBy\\":{\\"type\\":\\"column\\",\\"columnId\\":\\"4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\"},\\"orderDirection\\":\\"desc\\"},\\"id\\":\\"96352896-c508-4fca-90d8-66e9ebfce621\\"},\\"col-1-4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"operationType\\":\\"count\\",\\"isBucketed\\":false,\\"scale\\":\\"ratio\\",\\"sourceField\\":\\"Records\\",\\"id\\":\\"4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\"}}"}\n tables={esaggs index="ff959d40-b880-11e8-a6d9-e546fe2bba5f" metricsAtAllLevels=true partialRows=true includeFormatHints=true aggConfigs="[{\\"id\\":\\"77d8383e-f66e-471e-ae50-c427feedb5ba\\",\\"enabled\\":true,\\"type\\":\\"terms\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"geoip.country_iso_code\\",\\"orderBy\\":\\"a5c1b82d-51de-4448-a99d-6391432c3a03\\",\\"order\\":\\"desc\\",\\"size\\":5,\\"otherBucket\\":false,\\"otherBucketLabel\\":\\"Other\\",\\"missingBucket\\":false,\\"missingBucketLabel\\":\\"Missing\\"}},{\\"id\\":\\"a5c1b82d-51de-4448-a99d-6391432c3a03\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}}]" | lens_rename_columns idMap="{\\"col-0-77d8383e-f66e-471e-ae50-c427feedb5ba\\":{\\"label\\":\\"Top values of geoip.country_iso_code\\",\\"dataType\\":\\"string\\",\\"operationType\\":\\"terms\\",\\"scale\\":\\"ordinal\\",\\"sourceField\\":\\"geoip.country_iso_code\\",\\"isBucketed\\":true,\\"params\\":{\\"size\\":5,\\"orderBy\\":{\\"type\\":\\"column\\",\\"columnId\\":\\"a5c1b82d-51de-4448-a99d-6391432c3a03\\"},\\"orderDirection\\":\\"desc\\"},\\"id\\":\\"77d8383e-f66e-471e-ae50-c427feedb5ba\\"},\\"col-1-a5c1b82d-51de-4448-a99d-6391432c3a03\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"operationType\\":\\"count\\",\\"isBucketed\\":false,\\"scale\\":\\"ratio\\",\\"sourceField\\":\\"Records\\",\\"id\\":\\"a5c1b82d-51de-4448-a99d-6391432c3a03\\"}}"}\n| lens_xy_chart xTitle="Top values of geo.src" yTitle="Count of records" legend={lens_xy_legendConfig isVisible=true position="right"} fittingFunction="None" \n layers={lens_xy_layer layerId="9a27f85d-35a9-4246-81b2-48e7ee9b0707" hide=false xAccessor="96352896-c508-4fca-90d8-66e9ebfce621" yScaleType="linear" xScaleType="ordinal" isHistogram=false seriesType="bar" accessors="4ce9b4c7-2ebf-4d48-8669-0ea69d973353" columnToLabel="{\\"4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\":\\"Count of records\\"}"}\n layers={lens_xy_layer layerId="3b7791e9-326e-40d5-a787-b7594e48d906" hide=false xAccessor="77d8383e-f66e-471e-ae50-c427feedb5ba" yScaleType="linear" xScaleType="ordinal" isHistogram=false seriesType="bar" accessors="a5c1b82d-51de-4448-a99d-6391432c3a03" columnToLabel="{\\"a5c1b82d-51de-4448-a99d-6391432c3a03\\":\\"Count of records [1]\\"}"}', + state: { + datasourceMetaData: { + filterableIndexPatterns: [ + { id: '90943e30-9a47-11e8-b64d-95841ca0b247', title: 'kibana_sample_data_logs' }, + { id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', title: 'kibana_sample_data_ecommerce' }, + ], + }, + datasourceStates: { + indexpattern: { + currentIndexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + layers: { + '3b7791e9-326e-40d5-a787-b7594e48d906': { + columnOrder: [ + '77d8383e-f66e-471e-ae50-c427feedb5ba', + 'a5c1b82d-51de-4448-a99d-6391432c3a03', + ], + columns: { + '77d8383e-f66e-471e-ae50-c427feedb5ba': { + dataType: 'string', + isBucketed: true, + label: 'Top values of geoip.country_iso_code', + operationType: 'terms', + params: { + orderBy: { + columnId: 'a5c1b82d-51de-4448-a99d-6391432c3a03', + type: 'column', + }, + orderDirection: 'desc', + size: 5, + }, + scale: 'ordinal', + sourceField: 'geoip.country_iso_code', + }, + 'a5c1b82d-51de-4448-a99d-6391432c3a03': { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + operationType: 'count', + scale: 'ratio', + sourceField: 'Records', + }, + }, + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + }, + '9a27f85d-35a9-4246-81b2-48e7ee9b0707': { + columnOrder: [ + '96352896-c508-4fca-90d8-66e9ebfce621', + '4ce9b4c7-2ebf-4d48-8669-0ea69d973353', + ], + columns: { + '4ce9b4c7-2ebf-4d48-8669-0ea69d973353': { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + operationType: 'count', + scale: 'ratio', + sourceField: 'Records', + }, + '96352896-c508-4fca-90d8-66e9ebfce621': { + dataType: 'string', + isBucketed: true, + label: 'Top values of geo.src', + operationType: 'terms', + params: { + orderBy: { + columnId: '4ce9b4c7-2ebf-4d48-8669-0ea69d973353', + type: 'column', + }, + orderDirection: 'desc', + size: 5, + }, + scale: 'ordinal', + sourceField: 'geo.src', + }, + }, + indexPatternId: '90943e30-9a47-11e8-b64d-95841ca0b247', + }, + }, + }, + }, + filters: [ + { + $state: { store: 'appState' }, + meta: { + alias: null, + disabled: false, + index: '90943e30-9a47-11e8-b64d-95841ca0b247', + key: 'geo.src', + negate: true, + params: { query: 'CN' }, + type: 'phrase', + }, + query: { match_phrase: { 'geo.src': 'CN' } }, + }, + { + $state: { store: 'appState' }, + meta: { + alias: null, + disabled: false, + index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + key: 'geoip.country_iso_code', + negate: true, + params: { query: 'US' }, + type: 'phrase', + }, + query: { match_phrase: { 'geoip.country_iso_code': 'US' } }, + }, + ], + query: { language: 'kuery', query: 'NOT bytes > 5000' }, + visualization: { + fittingFunction: 'None', + layers: [ + { + accessors: ['4ce9b4c7-2ebf-4d48-8669-0ea69d973353'], + layerId: '9a27f85d-35a9-4246-81b2-48e7ee9b0707', + position: 'top', + seriesType: 'bar', + showGridlines: false, + xAccessor: '96352896-c508-4fca-90d8-66e9ebfce621', + }, + { + accessors: ['a5c1b82d-51de-4448-a99d-6391432c3a03'], + layerId: '3b7791e9-326e-40d5-a787-b7594e48d906', + seriesType: 'bar', + xAccessor: '77d8383e-f66e-471e-ae50-c427feedb5ba', + }, + ], + legend: { isVisible: true, position: 'right' }, + preferredSeriesType: 'bar', + }, + }, + title: 'mylens', + visualizationType: 'lnsXY', + }, + type: 'lens', + }; + + it('should remove expression', () => { + const result = migrations['7.10.0'](example, context); + expect(result.attributes.expression).toBeUndefined(); + }); + + it('should list references for layers', () => { + const result = migrations['7.10.0'](example, context); + expect( + result.references?.find( + (ref) => ref.name === 'indexpattern-datasource-layer-3b7791e9-326e-40d5-a787-b7594e48d906' + )?.id + ).toEqual('ff959d40-b880-11e8-a6d9-e546fe2bba5f'); + expect( + result.references?.find( + (ref) => ref.name === 'indexpattern-datasource-layer-9a27f85d-35a9-4246-81b2-48e7ee9b0707' + )?.id + ).toEqual('90943e30-9a47-11e8-b64d-95841ca0b247'); + }); + + it('should remove index pattern ids from layers', () => { + const result = migrations['7.10.0'](example, context); + expect( + result.attributes.state.datasourceStates.indexpattern.layers[ + '3b7791e9-326e-40d5-a787-b7594e48d906' + ].indexPatternId + ).toBeUndefined(); + expect( + result.attributes.state.datasourceStates.indexpattern.layers[ + '9a27f85d-35a9-4246-81b2-48e7ee9b0707' + ].indexPatternId + ).toBeUndefined(); + }); + + it('should remove datsource meta data', () => { + const result = migrations['7.10.0'](example, context); + expect(result.attributes.state.datasourceMetaData).toBeUndefined(); + }); + + it('should list references for filters', () => { + const result = migrations['7.10.0'](example, context); + expect(result.references?.find((ref) => ref.name === 'filter-index-pattern-0')?.id).toEqual( + '90943e30-9a47-11e8-b64d-95841ca0b247' + ); + expect(result.references?.find((ref) => ref.name === 'filter-index-pattern-1')?.id).toEqual( + 'ff959d40-b880-11e8-a6d9-e546fe2bba5f' + ); + }); + + it('should remove index pattern ids from filters', () => { + const result = migrations['7.10.0'](example, context); + expect(result.attributes.state.filters[0].meta.index).toBeUndefined(); + expect(result.attributes.state.filters[0].meta.indexRefName).toEqual( + 'filter-index-pattern-0' + ); + expect(result.attributes.state.filters[1].meta.index).toBeUndefined(); + expect(result.attributes.state.filters[1].meta.indexRefName).toEqual( + 'filter-index-pattern-1' + ); + }); + + it('should list reference for current index pattern', () => { + const result = migrations['7.10.0'](example, context); + expect( + result.references?.find( + (ref) => ref.name === 'indexpattern-datasource-current-indexpattern' + )?.id + ).toEqual('ff959d40-b880-11e8-a6d9-e546fe2bba5f'); + }); + + it('should remove current index pattern id from datasource state', () => { + const result = migrations['7.10.0'](example, context); + expect( + result.attributes.state.datasourceStates.indexpattern.currentIndexPatternId + ).toBeUndefined(); + }); + + it('should produce a valid document', () => { + const result = migrations['7.10.0'](example, context); + // changes to the outcome of this are critical - this test is a safe guard to not introduce changes accidentally + // if this test fails, make extra sure it's expected + expect(result).toMatchSnapshot(); + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations.ts b/x-pack/plugins/lens/server/migrations.ts index d24a3e92cbd9c..fdbfa1e455f60 100644 --- a/x-pack/plugins/lens/server/migrations.ts +++ b/x-pack/plugins/lens/server/migrations.ts @@ -6,11 +6,16 @@ import { cloneDeep } from 'lodash'; import { fromExpression, toExpression, Ast, ExpressionFunctionAST } from '@kbn/interpreter/common'; -import { SavedObjectMigrationMap, SavedObjectMigrationFn } from 'src/core/server'; +import { + SavedObjectMigrationMap, + SavedObjectMigrationFn, + SavedObjectReference, + SavedObjectUnsanitizedDoc, +} from 'src/core/server'; +import { Query, Filter } from 'src/plugins/data/public'; +import { PersistableFilter } from '../common'; -interface LensDocShape { - id?: string; - type?: string; +interface LensDocShapePre710 { visualizationType: string | null; title: string; expression: string | null; @@ -21,18 +26,44 @@ interface LensDocShape { datasourceStates: { // This is hardcoded as our only datasource indexpattern: { + currentIndexPatternId: string; layers: Record< string, { columnOrder: string[]; columns: Record; + indexPatternId: string; } >; }; }; visualization: VisualizationState; - query: unknown; - filters: unknown[]; + query: Query; + filters: Filter[]; + }; +} + +interface LensDocShape { + id?: string; + type?: string; + visualizationType: string | null; + title: string; + state: { + datasourceStates: { + // This is hardcoded as our only datasource + indexpattern: { + layers: Record< + string, + { + columnOrder: string[]; + columns: Record; + } + >; + }; + }; + visualization: VisualizationState; + query: Query; + filters: PersistableFilter[]; }; } @@ -55,7 +86,10 @@ interface XYStatePost77 { * Removes the `lens_auto_date` subexpression from a stored expression * string. For example: aggConfigs={lens_auto_date aggConfigs="JSON string"} */ -const removeLensAutoDate: SavedObjectMigrationFn = (doc, context) => { +const removeLensAutoDate: SavedObjectMigrationFn = ( + doc, + context +) => { const expression = doc.attributes.expression; if (!expression) { return doc; @@ -112,7 +146,10 @@ const removeLensAutoDate: SavedObjectMigrationFn = ( /** * Adds missing timeField arguments to esaggs in the Lens expression */ -const addTimeFieldToEsaggs: SavedObjectMigrationFn = (doc, context) => { +const addTimeFieldToEsaggs: SavedObjectMigrationFn = ( + doc, + context +) => { const expression = doc.attributes.expression; if (!expression) { return doc; @@ -174,14 +211,14 @@ const addTimeFieldToEsaggs: SavedObjectMigrationFn = }; const removeInvalidAccessors: SavedObjectMigrationFn< - LensDocShape, - LensDocShape + LensDocShapePre710, + LensDocShapePre710 > = (doc) => { const newDoc = cloneDeep(doc); if (newDoc.attributes.visualizationType === 'lnsXY') { const datasourceLayers = newDoc.attributes.state.datasourceStates.indexpattern.layers || {}; const xyState = newDoc.attributes.state.visualization; - (newDoc.attributes as LensDocShape< + (newDoc.attributes as LensDocShapePre710< XYStatePost77 >).state.visualization.layers = xyState.layers.map((layer: XYLayerPre77) => { const layerId = layer.layerId; @@ -197,9 +234,86 @@ const removeInvalidAccessors: SavedObjectMigrationFn< return newDoc; }; +const extractReferences: SavedObjectMigrationFn = ({ + attributes, + references, + ...docMeta +}) => { + const savedObjectReferences: SavedObjectReference[] = []; + // add currently selected index pattern to reference list + savedObjectReferences.push({ + type: 'index-pattern', + id: attributes.state.datasourceStates.indexpattern.currentIndexPatternId, + name: 'indexpattern-datasource-current-indexpattern', + }); + + // add layer index patterns to list and remove index pattern ids from layers + const persistableLayers: Record< + string, + Omit< + LensDocShapePre710['state']['datasourceStates']['indexpattern']['layers'][string], + 'indexPatternId' + > + > = {}; + Object.entries(attributes.state.datasourceStates.indexpattern.layers).forEach( + ([layerId, { indexPatternId, ...persistableLayer }]) => { + savedObjectReferences.push({ + type: 'index-pattern', + id: indexPatternId, + name: `indexpattern-datasource-layer-${layerId}`, + }); + persistableLayers[layerId] = persistableLayer; + } + ); + + // add filter index patterns to reference list and remove index pattern ids from filter definitions + const persistableFilters = attributes.state.filters.map((filterRow, i) => { + if (!filterRow.meta || !filterRow.meta.index) { + return filterRow; + } + const refName = `filter-index-pattern-${i}`; + savedObjectReferences.push({ + name: refName, + type: 'index-pattern', + id: filterRow.meta.index, + }); + return { + ...filterRow, + meta: { + ...filterRow.meta, + indexRefName: refName, + index: undefined, + }, + }; + }); + + // put together new saved object format + const newDoc: SavedObjectUnsanitizedDoc = { + ...docMeta, + references: savedObjectReferences, + attributes: { + visualizationType: attributes.visualizationType, + title: attributes.title, + state: { + datasourceStates: { + indexpattern: { + layers: persistableLayers, + }, + }, + visualization: attributes.state.visualization, + query: attributes.state.query, + filters: persistableFilters, + }, + }, + }; + + return newDoc; +}; + export const migrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs // sitting directly on the esaggs as an argument and not a nested function (which lens_auto_date was). '7.8.0': (doc, context) => addTimeFieldToEsaggs(removeLensAutoDate(doc, context), context), + '7.10.0': extractReferences, };