diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index 74b4edde21ae0..51dd883211620 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -20,6 +20,7 @@ import { UsageCollectionSetup } from '../../usage_collection/server'; import { AutocompleteService } from './autocomplete'; import { FieldFormatsSetup, FieldFormatsStart } from '../../field_formats/server'; import { getUiSettings } from './ui_settings'; +import { QuerySetup } from './query'; interface DataEnhancements { search: SearchEnhancements; @@ -27,6 +28,7 @@ interface DataEnhancements { export interface DataPluginSetup { search: ISearchSetup; + query: QuerySetup; /** * @deprecated - use "fieldFormats" plugin directly instead */ @@ -88,7 +90,7 @@ export class DataServerPlugin { bfetch, expressions, usageCollection, fieldFormats }: DataPluginSetupDependencies ) { this.scriptsService.setup(core); - this.queryService.setup(core); + const querySetup = this.queryService.setup(core); this.autocompleteService.setup(core); this.kqlTelemetryService.setup(core, { usageCollection }); @@ -105,6 +107,7 @@ export class DataServerPlugin searchSetup.__enhance(enhancements.search); }, search: searchSetup, + query: querySetup, fieldFormats, }; } diff --git a/src/plugins/data/server/query/index.ts b/src/plugins/data/server/query/index.ts index 1f394e9fc89f9..4439c17704642 100644 --- a/src/plugins/data/server/query/index.ts +++ b/src/plugins/data/server/query/index.ts @@ -7,3 +7,4 @@ */ export { QueryService } from './query_service'; +export type { QuerySetup } from './query_service'; diff --git a/src/plugins/data/server/query/query_service.ts b/src/plugins/data/server/query/query_service.ts index 173abeda0c951..33aeecbc11df1 100644 --- a/src/plugins/data/server/query/query_service.ts +++ b/src/plugins/data/server/query/query_service.ts @@ -36,3 +36,6 @@ export class QueryService implements Plugin { public start() {} } + +/** @public */ +export type QuerySetup = ReturnType; diff --git a/src/plugins/kibana_utils/common/persistable_state/types.ts b/src/plugins/kibana_utils/common/persistable_state/types.ts index f5aa73e9fe205..db757ac662686 100644 --- a/src/plugins/kibana_utils/common/persistable_state/types.ts +++ b/src/plugins/kibana_utils/common/persistable_state/types.ts @@ -92,8 +92,8 @@ export interface PersistableState

}; export type MigrateFunction< - FromVersion extends SerializableRecord = SerializableRecord, - ToVersion extends SerializableRecord = SerializableRecord + FromVersion extends Serializable = SerializableRecord, + ToVersion extends Serializable = SerializableRecord > = (state: FromVersion) => ToVersion; /** diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 841831b70eac5..f13139605afda 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -6,7 +6,7 @@ */ import { SavedObject, SavedObjectsFindResponse } from 'kibana/server'; -import { lensEmbeddableFactory } from '../../../lens/server/embeddable/lens_embeddable_factory'; +import { makeLensEmbeddableFactory } from '../../../lens/server/embeddable/make_lens_embeddable_factory'; import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; import { AssociationType, @@ -879,7 +879,7 @@ describe('common utils', () => { ].join('\n\n'); const extractedReferences = extractLensReferencesFromCommentString( - lensEmbeddableFactory, + makeLensEmbeddableFactory({}), commentString ); @@ -977,12 +977,16 @@ describe('common utils', () => { )}},"editMode":false}}`, ].join('\n\n'); - const updatedReferences = getOrUpdateLensReferences(lensEmbeddableFactory, newCommentString, { - references: currentCommentReferences, - attributes: { - comment: currentCommentString, - }, - } as SavedObject); + const updatedReferences = getOrUpdateLensReferences( + makeLensEmbeddableFactory({}), + newCommentString, + { + references: currentCommentReferences, + attributes: { + comment: currentCommentString, + }, + } as SavedObject + ); const expectedReferences = [ ...nonLensCurrentCommentReferences, diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts index 385c1c5945a11..1983ae0db51d3 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts @@ -17,7 +17,7 @@ import { } from '../../../common/utils/markdown_plugins/utils'; import { savedObjectsServiceMock } from '../../../../../../src/core/server/mocks'; -import { lensEmbeddableFactory } from '../../../../lens/server/embeddable/lens_embeddable_factory'; +import { makeLensEmbeddableFactory } from '../../../../lens/server/embeddable/make_lens_embeddable_factory'; import { LensDocShape715 } from '../../../../lens/server'; import { SavedObjectReference, @@ -32,7 +32,7 @@ import { SerializableRecord } from '@kbn/utility-types'; describe('comments migrations', () => { const migrations = createCommentsMigrations({ - lensEmbeddableFactory, + lensEmbeddableFactory: makeLensEmbeddableFactory({}), }); const contextMock = savedObjectsServiceMock.createMigrationContext(); 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 08f046925cb46..7a0601f9673cc 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -126,11 +126,13 @@ describe('Lens App', () => { defaultSavedObjectId = '1234'; defaultDoc = { savedObjectId: defaultSavedObjectId, + visualizationType: 'testVis', + type: 'lens', title: 'An extremely cool default document!', expression: 'definitely a valid expression', state: { query: 'lucene', - filters: [{ query: { match_phrase: { src: 'test' } } }], + filters: [{ query: { match_phrase: { src: 'test' } }, meta: { index: 'index-pattern-0' } }], }, references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }], } as unknown as Document; @@ -685,7 +687,7 @@ describe('Lens App', () => { savedObjectId: defaultSavedObjectId, title: 'hello there2', state: expect.objectContaining({ - filters: [unpinned], + filters: services.data.query.filterManager.inject([unpinned], []), }), }), true, @@ -1252,24 +1254,28 @@ describe('Lens App', () => { }); it('should not confirm when changes are saved', async () => { - const { props } = await mountWith({ - preloadedState: { - persistedDoc: { - ...defaultDoc, - state: { - ...defaultDoc.state, - datasourceStates: { testDatasource: {} }, - visualization: {}, - }, - }, - isSaveable: true, - ...(defaultDoc.state as Partial), - visualization: { - activeId: 'testVis', - state: {}, + const preloadedState = { + persistedDoc: { + ...defaultDoc, + state: { + ...defaultDoc.state, + datasourceStates: { testDatasource: {} }, + visualization: {}, }, }, - }); + isSaveable: true, + ...(defaultDoc.state as Partial), + visualization: { + activeId: 'testVis', + state: {}, + }, + }; + + const customProps = makeDefaultProps(); + customProps.datasourceMap.testDatasource.isEqual = () => true; // if this returns false, the documents won't be accounted equal + + const { props } = await mountWith({ preloadedState, props: customProps }); + const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0]; lastCall({ default: defaultLeave, confirm: confirmLeave }); expect(defaultLeave).toHaveBeenCalled(); diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 5638a35d1cc6d..78e739a6324ec 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -7,8 +7,7 @@ import './app.scss'; -import { isEqual } from 'lodash'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiBreadcrumb } from '@elastic/eui'; import { @@ -32,13 +31,10 @@ import { DispatchSetState, selectSavedObjectFormat, } from '../state_management'; -import { - SaveModalContainer, - getLastKnownDocWithoutPinnedFilters, - runSaveLensVisualization, -} from './save_modal_container'; +import { SaveModalContainer, runSaveLensVisualization } from './save_modal_container'; import { LensInspector } from '../lens_inspector_service'; import { getEditPath } from '../../common'; +import { isLensEqual } from './lens_document_equality'; export type SaveProps = Omit & { returnToOrigin: boolean; @@ -92,8 +88,17 @@ export function App({ isSaveable, } = useLensSelector((state) => state.lens); + const selectorDependencies = useMemo( + () => ({ + datasourceMap, + visualizationMap, + extractFilterReferences: data.query.filterManager.extract, + }), + [datasourceMap, visualizationMap, data.query.filterManager.extract] + ); + const currentDoc = useLensSelector((state) => - selectSavedObjectFormat(state, datasourceMap, visualizationMap) + selectSavedObjectFormat(state, selectorDependencies) ); // Used to show a popover that guides the user towards changing the date range when no data is available. @@ -146,12 +151,9 @@ export function App({ 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 ( application.capabilities.visualize.save && - !isEqual(persistedDoc?.state, getLastKnownDocWithoutPinnedFilters(lastKnownDoc)?.state) && + !isLensEqual(persistedDoc, lastKnownDoc, data.query.filterManager.inject, datasourceMap) && (isSaveable || persistedDoc) ) { return actions.confirm( @@ -166,7 +168,15 @@ export function App({ return actions.default(); } }); - }, [onAppLeave, lastKnownDoc, isSaveable, persistedDoc, application.capabilities.visualize.save]); + }, [ + onAppLeave, + lastKnownDoc, + isSaveable, + persistedDoc, + application.capabilities.visualize.save, + data.query.filterManager.inject, + datasourceMap, + ]); const getLegacyUrlConflictCallout = useCallback(() => { // This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario diff --git a/x-pack/plugins/lens/public/app_plugin/lens_document_equality.test.ts b/x-pack/plugins/lens/public/app_plugin/lens_document_equality.test.ts new file mode 100644 index 0000000000000..8bd1e6e980bbb --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/lens_document_equality.test.ts @@ -0,0 +1,248 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Filter, FilterStateStore } from '@kbn/es-query'; +import { isLensEqual } from './lens_document_equality'; +import { Document } from '../persistence/saved_object_store'; +import { Datasource, DatasourceMap } from '../types'; + +const defaultDoc: Document = { + title: 'some-title', + visualizationType: 'lnsXY', + state: { + query: { + query: '', + language: 'kuery', + }, + visualization: { + some: 'props', + }, + datasourceStates: { + indexpattern: {}, + }, + filters: [ + { + meta: { + index: 'reference-1', + }, + }, + ], + }, + references: [ + { + name: 'reference-1', + id: 'id-1', + type: 'index-pattern', + }, + ], +}; + +describe('lens document equality', () => { + const mockInjectFilterReferences = jest.fn((filters: Filter[]) => + filters.map((filter) => ({ + ...filter, + meta: { + ...filter.meta, + index: 'injected!', + }, + })) + ); + + let mockDatasourceMap: DatasourceMap; + + beforeEach(() => { + mockDatasourceMap = { + indexpattern: { isEqual: jest.fn(() => true) }, + } as unknown as DatasourceMap; + }); + + it('returns true when documents are equal', () => { + expect( + isLensEqual(defaultDoc, defaultDoc, mockInjectFilterReferences, mockDatasourceMap) + ).toBeTruthy(); + }); + + it('handles undefined documents', () => { + expect(isLensEqual(undefined, undefined, mockInjectFilterReferences, {})).toBeTruthy(); + expect(isLensEqual(undefined, {} as Document, mockInjectFilterReferences, {})).toBeFalsy(); + expect(isLensEqual({} as Document, undefined, mockInjectFilterReferences, {})).toBeFalsy(); + }); + + it('should compare visualization type', () => { + expect( + isLensEqual( + defaultDoc, + { ...defaultDoc, visualizationType: 'other-type' }, + mockInjectFilterReferences, + mockDatasourceMap + ) + ).toBeFalsy(); + }); + + it('should compare the query', () => { + expect( + isLensEqual( + defaultDoc, + { + ...defaultDoc, + state: { + ...defaultDoc.state, + query: { + query: 'foobar', + language: 'kuery', + }, + }, + }, + mockInjectFilterReferences, + mockDatasourceMap + ) + ).toBeFalsy(); + }); + + it('should compare the visualization state', () => { + expect( + isLensEqual( + defaultDoc, + { + ...defaultDoc, + state: { + ...defaultDoc.state, + visualization: { + some: 'other-props', + }, + }, + }, + mockInjectFilterReferences, + mockDatasourceMap + ) + ).toBeFalsy(); + }); + + describe('comparing the datasources', () => { + it('checks available datasources', () => { + // add an extra datasource in one doc + expect( + isLensEqual( + defaultDoc, + { + ...defaultDoc, + state: { + ...defaultDoc.state, + datasourceStates: { + ...defaultDoc.state.datasourceStates, + foodatasource: {}, + }, + }, + }, + mockInjectFilterReferences, + { ...mockDatasourceMap, foodatasource: { isEqual: () => true } as unknown as Datasource } + ) + ).toBeFalsy(); + + // ordering of the datasource states shouldn't matter + expect( + isLensEqual( + { + ...defaultDoc, + state: { + ...defaultDoc.state, + datasourceStates: { + foodatasource: {}, // first + ...defaultDoc.state.datasourceStates, + }, + }, + }, + { + ...defaultDoc, + state: { + ...defaultDoc.state, + datasourceStates: { + ...defaultDoc.state.datasourceStates, + foodatasource: {}, // last + }, + }, + }, + mockInjectFilterReferences, + { ...mockDatasourceMap, foodatasource: { isEqual: () => true } as unknown as Datasource } + ) + ).toBeTruthy(); + }); + + it('delegates internal datasource comparison', () => { + // datasource's isEqual returns false + (mockDatasourceMap.indexpattern.isEqual as jest.Mock).mockReturnValue(false); + expect( + isLensEqual(defaultDoc, defaultDoc, mockInjectFilterReferences, mockDatasourceMap) + ).toBeFalsy(); + }); + }); + + it('should ignore pinned filters', () => { + // ignores pinned filters + const pinnedFilter: Filter = { + $state: { + store: FilterStateStore.GLOBAL_STATE, + }, + meta: {}, + }; + + const filtersWithPinned = [...defaultDoc.state.filters, pinnedFilter]; + + expect( + isLensEqual( + defaultDoc, + { ...defaultDoc, state: { ...defaultDoc.state, filters: filtersWithPinned } }, + mockInjectFilterReferences, + mockDatasourceMap + ) + ).toBeTruthy(); + }); + + it('should inject filter references', () => { + // injects filter references for comparison + expect( + isLensEqual( + defaultDoc, + { + ...defaultDoc, + state: { + ...defaultDoc.state, + filters: [ + { + meta: { + index: 'some-other-reference-name', + }, + }, + ], + }, + }, + mockInjectFilterReferences, + mockDatasourceMap + ) + ).toBeTruthy(); + }); + + it('should consider undefined props equivalent to non-existant props', () => { + expect( + isLensEqual( + defaultDoc, + { + ...defaultDoc, + state: { + ...defaultDoc.state, + visualization: { + ...(defaultDoc.state.visualization as object), + foo: undefined, + }, + }, + }, + mockInjectFilterReferences, + mockDatasourceMap + ) + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/lens/public/app_plugin/lens_document_equality.ts b/x-pack/plugins/lens/public/app_plugin/lens_document_equality.ts new file mode 100644 index 0000000000000..3e833502c0592 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/lens_document_equality.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEqual, intersection, union } from 'lodash'; +import { FilterManager } from 'src/plugins/data/public'; +import { Document } from '../persistence/saved_object_store'; +import { DatasourceMap } from '../types'; +import { injectDocFilterReferences, removePinnedFilters } from './save_modal_container'; + +const removeNonSerializable = (obj: Parameters[0]) => + JSON.parse(JSON.stringify(obj)); + +export const isLensEqual = ( + doc1In: Document | undefined, + doc2In: Document | undefined, + injectFilterReferences: FilterManager['inject'], + datasourceMap: DatasourceMap +) => { + if (doc1In === undefined || doc2In === undefined) { + return doc1In === doc2In; + } + + // we do this so that undefined props are the same as non-existant props + const doc1 = removeNonSerializable(doc1In); + const doc2 = removeNonSerializable(doc2In); + + if (doc1?.visualizationType !== doc2?.visualizationType) { + return false; + } + + if (!isEqual(doc1.state.query, doc2.state.query)) { + return false; + } + + if (!isEqual(doc1.state.visualization, doc2.state.visualization)) { + return false; + } + + // data source equality + const availableDatasourceTypes1 = Object.keys(doc1.state.datasourceStates); + const availableDatasourceTypes2 = Object.keys(doc2.state.datasourceStates); + + let datasourcesEqual = + intersection(availableDatasourceTypes1, availableDatasourceTypes2).length === + union(availableDatasourceTypes1, availableDatasourceTypes2).length; + + if (datasourcesEqual) { + // equal so far, so actually check + datasourcesEqual = availableDatasourceTypes1 + .map((type) => + datasourceMap[type].isEqual( + doc1.state.datasourceStates[type], + doc1.references, + doc2.state.datasourceStates[type], + doc2.references + ) + ) + .every((res) => res); + } + + if (!datasourcesEqual) { + return false; + } + + const [filtersInjected1, filtersInjected2] = [doc1, doc2].map((doc) => + removePinnedFilters(injectDocFilterReferences(injectFilterReferences, doc)) + ); + if (!isEqual(filtersInjected1?.state.filters, filtersInjected2?.state.filters)) { + return false; + } + + return true; +}; diff --git a/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx b/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx index b3bc40ef5ac19..ae144cdeb1068 100644 --- a/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx +++ b/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx @@ -8,15 +8,14 @@ import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; -import { partition } from 'lodash'; import type { SavedObjectReference } from 'kibana/public'; import { SaveModal } from './save_modal'; import type { LensAppProps, LensAppServices } from './types'; import type { SaveProps } from './app'; -import { Document, injectFilterReferences, checkForDuplicateTitle } from '../persistence'; +import { Document, checkForDuplicateTitle } from '../persistence'; import type { LensByReferenceInput, LensEmbeddableInput } from '../embeddable'; -import { esFilters } from '../../../../../src/plugins/data/public'; +import { esFilters, FilterManager } from '../../../../../src/plugins/data/public'; import { APP_ID, getFullPath, LENS_EMBEDDABLE_TYPE } from '../../common'; import { trackUiEvent } from '../lens_ui_telemetry'; import type { LensAppState } from '../state_management'; @@ -169,10 +168,11 @@ const redirectToDashboard = ({ const getDocToSave = ( lastKnownDoc: Document, saveProps: SaveProps, - references: SavedObjectReference[] + references: SavedObjectReference[], + injectFilterReferences: FilterManager['inject'] ) => { const docToSave = { - ...getLastKnownDocWithoutPinnedFilters(lastKnownDoc)!, + ...injectDocFilterReferences(injectFilterReferences, removePinnedFilters(lastKnownDoc))!, references, }; @@ -200,6 +200,7 @@ export const runSaveLensVisualization = async ( ): Promise | undefined> => { const { chrome, + data, initialInput, originatingApp, lastKnownDoc, @@ -240,7 +241,12 @@ export const runSaveLensVisualization = async ( ); } - const docToSave = getDocToSave(lastKnownDoc, saveProps, references); + const docToSave = getDocToSave( + lastKnownDoc, + saveProps, + references, + data.query.filterManager.inject + ); // Required to serialize filters in by value mode until // https://github.com/elastic/kibana/issues/77588 is fixed @@ -351,21 +357,29 @@ export const runSaveLensVisualization = async ( } }; -export function getLastKnownDocWithoutPinnedFilters(doc?: Document) { +export function injectDocFilterReferences( + injectFilterReferences: FilterManager['inject'], + doc?: Document +) { if (!doc) return undefined; - const [pinnedFilters, appFilters] = partition( - injectFilterReferences(doc.state?.filters || [], doc.references), - esFilters.isFilterPinned - ); - return pinnedFilters?.length - ? { - ...doc, - state: { - ...doc.state, - filters: appFilters, - }, - } - : doc; + return { + ...doc, + state: { + ...doc.state, + filters: injectFilterReferences(doc.state?.filters || [], doc.references), + }, + }; +} + +export function removePinnedFilters(doc?: Document) { + if (!doc) return undefined; + return { + ...doc, + state: { + ...doc.state, + filters: (doc.state?.filters || []).filter((filter) => !esFilters.isFilterPinned(filter)), + }, + }; } // eslint-disable-next-line import/no-default-export diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx index ceb9388a561f0..17e18392e83e9 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx @@ -17,7 +17,7 @@ import { import { ReactExpressionRendererProps } from 'src/plugins/expressions/public'; import { spacesPluginMock } from '../../../spaces/public/mocks'; import { Filter } from '@kbn/es-query'; -import { Query, TimeRange, IndexPatternsContract } from 'src/plugins/data/public'; +import { Query, TimeRange, IndexPatternsContract, FilterManager } from 'src/plugins/data/public'; import { Document } from '../persistence'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public/embeddable'; @@ -72,6 +72,16 @@ const options = { checkForDuplicateTitle: defaultCheckForDuplicateTitle, }; +const mockInjectFilterReferences: FilterManager['inject'] = (filters, references) => { + return filters.map((filter) => ({ + ...filter, + meta: { + ...filter.meta, + index: 'injected!', + }, + })); +}; + const attributeServiceMockFromSavedVis = (document: Document): LensAttributeService => { const core = coreMock.createStart(); const service = new AttributeService< @@ -139,6 +149,7 @@ describe('embeddable', () => { getTrigger, theme: themeServiceMock.createStartContract(), visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), documentToExpression: () => Promise.resolve({ ast: { @@ -180,6 +191,7 @@ describe('embeddable', () => { capabilities: { canSaveDashboards: true, canSaveVisualizations: true }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -226,6 +238,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -283,6 +296,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -329,6 +343,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -370,6 +385,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -414,6 +430,7 @@ describe('embeddable', () => { capabilities: { canSaveDashboards: true, canSaveVisualizations: true }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -465,6 +482,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -514,6 +532,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -570,6 +589,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -592,7 +612,7 @@ describe('embeddable', () => { expect.objectContaining({ timeRange, query: [query, savedVis.state.query], - filters, + filters: mockInjectFilterReferences(filters, []), }) ); @@ -627,6 +647,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -663,9 +684,7 @@ describe('embeddable', () => { state: { ...savedVis.state, query: { language: 'kquery', query: 'saved filter' }, - filters: [ - { meta: { alias: 'test', negate: false, disabled: false, indexRefName: 'filter-0' } }, - ], + filters: [{ meta: { alias: 'test', negate: false, disabled: false, index: 'filter-0' } }], }, references: [{ type: 'index-pattern', name: 'filter-0', id: 'my-index-pattern-id' }], }; @@ -687,6 +706,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -708,11 +728,14 @@ describe('embeddable', () => { 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' } }, - ], + // actual index pattern id gets injected + filters: mockInjectFilterReferences( + [ + filters[0], + { meta: { alias: 'test', negate: false, disabled: false, index: 'injected!' } }, + ], + [] + ), }); }); @@ -731,6 +754,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -775,6 +799,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -819,6 +844,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -878,6 +904,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -953,6 +980,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -1003,6 +1031,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -1053,6 +1082,7 @@ describe('embeddable', () => { }, getTrigger, visualizationMap: {}, + injectFilterReferences: jest.fn(mockInjectFilterReferences), theme: themeServiceMock.createStartContract(), documentToExpression: () => Promise.resolve({ @@ -1124,6 +1154,7 @@ describe('embeddable', () => { }, getTrigger, theme: themeServiceMock.createStartContract(), + injectFilterReferences: jest.fn(mockInjectFilterReferences), visualizationMap: { [visDocument.visualizationType as string]: { onEditAction: onEditActionMock, diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 22acfb7aa063f..2878a484686d2 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -16,6 +16,7 @@ import type { TimefilterContract, TimeRange, IndexPattern, + FilterManager, } from 'src/plugins/data/public'; import type { PaletteOutput } from 'src/plugins/charts/public'; import type { Start as InspectorStart } from 'src/plugins/inspector/public'; @@ -42,7 +43,7 @@ import { SavedObjectEmbeddableInput, ReferenceOrValueEmbeddable, } from '../../../../../src/plugins/embeddable/public'; -import { Document, injectFilterReferences } from '../persistence'; +import { Document } from '../persistence'; import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper'; import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; import { @@ -107,6 +108,7 @@ export interface LensEmbeddableDeps { documentToExpression: ( doc: Document ) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>; + injectFilterReferences: FilterManager['inject']; visualizationMap: VisualizationMap; indexPatternService: IndexPatternsContract; expressionRenderer: ReactExpressionRendererType; @@ -477,7 +479,7 @@ export class Embeddable output.filters = [...this.savedVis.state.filters]; } - output.filters = injectFilterReferences(output.filters, this.savedVis.references); + output.filters = this.deps.injectFilterReferences(output.filters, this.savedVis.references); return output; } diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts index c1bc5aacc3943..fc335bf2f5f87 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/embeddable/embeddable_factory.ts @@ -10,7 +10,11 @@ import { i18n } from '@kbn/i18n'; import { RecursiveReadonly } from '@kbn/utility-types'; import { Ast } from '@kbn/interpreter'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; -import { IndexPatternsContract, TimefilterContract } from '../../../../../src/plugins/data/public'; +import { + FilterManager, + IndexPatternsContract, + TimefilterContract, +} from '../../../../../src/plugins/data/public'; import { ReactExpressionRendererType } from '../../../../../src/plugins/expressions/public'; import { EmbeddableFactoryDefinition, @@ -40,6 +44,7 @@ export interface LensEmbeddableStartServices { documentToExpression: ( doc: Document ) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>; + injectFilterReferences: FilterManager['inject']; visualizationMap: VisualizationMap; spaces?: SpacesPluginStart; theme: ThemeServiceStart; @@ -88,6 +93,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { timefilter, expressionRenderer, documentToExpression, + injectFilterReferences, visualizationMap, uiActions, coreHttp, @@ -113,6 +119,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { getTrigger: uiActions?.getTrigger, getTriggerCompatibleActions: uiActions?.getTriggerCompatibleActions, documentToExpression, + injectFilterReferences, visualizationMap, capabilities: { canSaveDashboards: Boolean(capabilities.dashboard?.showWriteControls), 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 f7be239d8fb36..072087e0de65c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts @@ -28,3 +28,5 @@ export function loadInitialState() { const originalLoader = jest.requireActual('../loader'); export const extractReferences = originalLoader.extractReferences; + +export const injectReferences = originalLoader.injectReferences; 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 0d1954564c6f3..cc88375e4137b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -29,6 +29,8 @@ import { indexPatternFieldEditorPluginMock } from 'src/plugins/data_view_field_e import { uiActionsPluginMock } from '../../../../../src/plugins/ui_actions/public/mocks'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { TinymathAST } from 'packages/kbn-tinymath'; +import { SavedObjectReference } from 'kibana/server'; +import { cloneDeep } from 'lodash'; jest.mock('./loader'); jest.mock('../id_generator'); @@ -1737,4 +1739,77 @@ describe('IndexPattern Data Source', () => { }); }); }); + + describe('#isEqual', () => { + const layerId = '8bd66b66-aba3-49fb-9ff2-4bf83f2be08e'; + + const persistableState: IndexPatternPersistedState = { + layers: { + [layerId]: { + columns: { + 'fa649155-d7f5-49d9-af26-508287431244': { + label: 'Count of records', + dataType: 'number', + operationType: 'count', + isBucketed: false, + scale: 'ratio', + sourceField: 'Records', + }, + }, + columnOrder: ['fa649155-d7f5-49d9-af26-508287431244'], + incompleteColumns: {}, + }, + }, + }; + + const currentIndexPatternReference = { + id: 'some-id', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }; + + const references1: SavedObjectReference[] = [ + currentIndexPatternReference, + { + id: 'some-id', + name: 'indexpattern-datasource-layer-8bd66b66-aba3-49fb-9ff2-4bf83f2be08e', + type: 'index-pattern', + }, + ]; + + const references2: SavedObjectReference[] = [ + currentIndexPatternReference, + { + id: 'some-DIFFERENT-id', + name: 'indexpattern-datasource-layer-8bd66b66-aba3-49fb-9ff2-4bf83f2be08e', + type: 'index-pattern', + }, + ]; + + it('should be false if datasource states are using different data views', () => { + expect( + indexPatternDatasource.isEqual(persistableState, references1, persistableState, references2) + ).toBe(false); + }); + + it('should be false if datasource states differ', () => { + const differentPersistableState = cloneDeep(persistableState); + differentPersistableState.layers[layerId].columnOrder = ['something else']; + + expect( + indexPatternDatasource.isEqual( + persistableState, + references1, + differentPersistableState, + references1 + ) + ).toBe(false); + }); + + it('should be true if datasource states are identical and they refer to the same data view', () => { + expect( + indexPatternDatasource.isEqual(persistableState, references1, persistableState, references1) + ).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 49a85f3f3af79..81acac82355ab 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -12,6 +12,7 @@ import type { CoreStart, SavedObjectReference } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import type { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import type { FieldFormatsStart } from 'src/plugins/field_formats/public'; +import { isEqual } from 'lodash'; import type { IndexPatternFieldEditorStart } from '../../../../../src/plugins/data_view_field_editor/public'; import type { DatasourceDimensionEditorProps, @@ -27,6 +28,7 @@ import { changeIndexPattern, changeLayerIndexPattern, extractReferences, + injectReferences, } from './loader'; import { toExpression } from './to_expression'; import { @@ -545,6 +547,16 @@ export function getIndexPatternDatasource({ }) ); }, + isEqual: ( + persistableState1: IndexPatternPersistedState, + references1: SavedObjectReference[], + persistableState2: IndexPatternPersistedState, + references2: SavedObjectReference[] + ) => + isEqual( + injectReferences(persistableState1, references1), + injectReferences(persistableState2, references2) + ), }; return indexPatternDatasource; diff --git a/x-pack/plugins/lens/public/mocks/data_plugin_mock.ts b/x-pack/plugins/lens/public/mocks/data_plugin_mock.ts index daab2566b28fe..865e21f5bb613 100644 --- a/x-pack/plugins/lens/public/mocks/data_plugin_mock.ts +++ b/x-pack/plugins/lens/public/mocks/data_plugin_mock.ts @@ -7,7 +7,7 @@ import { Observable, Subject } from 'rxjs'; import moment from 'moment'; -import { DataPublicPluginStart, esFilters } from '../../../../../src/plugins/data/public'; +import { DataPublicPluginStart, esFilters, Filter } from '../../../../../src/plugins/data/public'; function createMockTimefilter() { const unsubscribe = jest.fn(); @@ -90,6 +90,19 @@ export function mockDataPlugin( filters = []; subscriber(); }, + inject: (filtersIn: Filter[]) => { + return filtersIn.map((filter) => ({ + ...filter, + meta: { ...filter.meta, index: 'injected!' }, + })); + }, + extract: (filtersIn: Filter[]) => { + const state = filtersIn.map((filter) => ({ + ...filter, + meta: { ...filter.meta, index: 'extracted!' }, + })); + return { state, references: [] }; + }, }; } function createMockQueryString() { diff --git a/x-pack/plugins/lens/public/mocks/datasource_mock.ts b/x-pack/plugins/lens/public/mocks/datasource_mock.ts index 50df6f07cb5dc..ce36b575b30e3 100644 --- a/x-pack/plugins/lens/public/mocks/datasource_mock.ts +++ b/x-pack/plugins/lens/public/mocks/datasource_mock.ts @@ -51,6 +51,7 @@ export function createMockDatasource(id: string): DatasourceMock { checkIntegrity: jest.fn((_state) => []), isTimeBased: jest.fn(), isValidColumn: jest.fn(), + isEqual: jest.fn(), }; } diff --git a/x-pack/plugins/lens/public/mocks/services_mock.tsx b/x-pack/plugins/lens/public/mocks/services_mock.tsx index 5ec4f8db4a0ed..9fa6d61370a17 100644 --- a/x-pack/plugins/lens/public/mocks/services_mock.tsx +++ b/x-pack/plugins/lens/public/mocks/services_mock.tsx @@ -40,7 +40,7 @@ export const defaultDoc = { visualizationType: 'testVis', state: { query: 'kuery', - filters: [{ query: { match_phrase: { src: 'test' } } }], + filters: [{ query: { match_phrase: { src: 'test' } }, meta: { index: 'index-pattern-0' } }], datasourceStates: { testDatasource: 'datasource', }, diff --git a/x-pack/plugins/lens/public/persistence/filter_references.test.ts b/x-pack/plugins/lens/public/persistence/filter_references.test.ts deleted file mode 100644 index 8a86c6fdc9664..0000000000000 --- a/x-pack/plugins/lens/public/persistence/filter_references.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Filter } from '@kbn/es-query'; -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 remove index and value from persistable filter', () => { - const { persistableFilters } = extractFilterReferences([ - { ...filters[0], meta: { ...filters[0].meta, value: 'CN' } }, - { ...filters[1], meta: { ...filters[1].meta, value: 'US' } }, - ]); - expect(persistableFilters.length).toBe(2); - persistableFilters.forEach((filter) => { - expect(filter.meta.hasOwnProperty('index')).toBe(false); - expect(filter.meta.hasOwnProperty('value')).toBe(false); - }); - }); - - 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 deleted file mode 100644 index d080e2157c3d3..0000000000000 --- a/x-pack/plugins/lens/public/persistence/filter_references.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Filter } from '@kbn/es-query'; -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, - }); - const newFilter = { - ...filterRow, - meta: { - ...filterRow.meta, - indexRefName: refName, - }, - }; - // remove index because it's specified by indexRefName - delete newFilter.meta.index; - // remove value because it can't be persisted - delete newFilter.meta.value; - return newFilter; - }); - - 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 0fd3388ef416a..3fe987b78fbcc 100644 --- a/x-pack/plugins/lens/public/persistence/index.ts +++ b/x-pack/plugins/lens/public/persistence/index.ts @@ -6,5 +6,4 @@ */ export * from './saved_object_store'; -export * from './filter_references'; export { checkForDuplicateTitle } from './saved_objects_utils'; 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 bec604a0f7cfa..769f50f901243 100644 --- a/x-pack/plugins/lens/public/persistence/saved_object_store.ts +++ b/x-pack/plugins/lens/public/persistence/saved_object_store.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Filter } from '@kbn/es-query'; import { SavedObjectAttributes, SavedObjectsClientContract, @@ -12,7 +13,7 @@ import { ResolvedSimpleSavedObject, } from 'kibana/public'; import { Query } from '../../../../../src/plugins/data/public'; -import { DOC_TYPE, PersistableFilter } from '../../common'; +import { DOC_TYPE } from '../../common'; import { LensSavedObjectAttributes } from '../async_services'; export interface Document { @@ -29,7 +30,7 @@ export interface Document { activePaletteId: string; state?: unknown; }; - filters: PersistableFilter[]; + filters: Filter[]; }; references: SavedObjectReference[]; } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index ecf237ac1327d..6b26753b41880 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -212,6 +212,7 @@ export class LensPlugin { timefilter: plugins.data.query.timefilter.timefilter, expressionRenderer: plugins.expressions.ReactExpressionRenderer, documentToExpression: this.editorFrameService!.documentToExpression, + injectFilterReferences: data.query.filterManager.inject, visualizationMap, indexPatternService: plugins.data.indexPatterns, uiActions: plugins.uiActions, diff --git a/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap b/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap index efde7184ac731..7f70508dc423f 100644 --- a/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap +++ b/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap @@ -37,6 +37,9 @@ Object { }, "filters": Array [ Object { + "meta": Object { + "index": "index-pattern-0", + }, "query": Object { "match_phrase": Object { "src": "test", diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts index 11fd4c9061271..372d08017ee2a 100644 --- a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts +++ b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts @@ -16,7 +16,7 @@ import { getInitialDatasourceId } from '../../utils'; import { initializeDatasources } from '../../editor_frame_service/editor_frame'; import { LensAppServices } from '../../app_plugin/types'; import { getEditPath, getFullPath, LENS_EMBEDDABLE_TYPE } from '../../../common/constants'; -import { Document, injectFilterReferences } from '../../persistence'; +import { Document } from '../../persistence'; export const getPersisted = async ({ initialInput, @@ -162,7 +162,7 @@ export function loadInitial( {} ); - const filters = injectFilterReferences(doc.state.filters, doc.references); + const filters = data.query.filterManager.inject(doc.state.filters, doc.references); // Don't overwrite any pinned filters data.query.filterManager.setAppFilters(filters); diff --git a/x-pack/plugins/lens/public/state_management/load_initial.test.tsx b/x-pack/plugins/lens/public/state_management/load_initial.test.tsx index 1143bf8f7561e..1015838e11674 100644 --- a/x-pack/plugins/lens/public/state_management/load_initial.test.tsx +++ b/x-pack/plugins/lens/public/state_management/load_initial.test.tsx @@ -224,7 +224,7 @@ describe('Initializing the store', () => { }); expect(deps.lensServices.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([ - { query: { match_phrase: { src: 'test' } } }, + { query: { match_phrase: { src: 'test' } }, meta: { index: 'injected!' } }, ]); expect(store.getState()).toEqual({ diff --git a/x-pack/plugins/lens/public/state_management/selectors.ts b/x-pack/plugins/lens/public/state_management/selectors.ts index 4b201e35e5cf7..250e9dde31373 100644 --- a/x-pack/plugins/lens/public/state_management/selectors.ts +++ b/x-pack/plugins/lens/public/state_management/selectors.ts @@ -7,8 +7,8 @@ import { createSelector } from '@reduxjs/toolkit'; import { SavedObjectReference } from 'kibana/server'; +import { FilterManager } from 'src/plugins/data/public'; import { LensState } from './types'; -import { extractFilterReferences } from '../persistence'; import { Datasource, DatasourceMap, VisualizationMap } from '../types'; import { getDatasourceLayers } from '../editor_frame_service/editor_frame'; @@ -43,13 +43,10 @@ export const selectExecutionContextSearch = createSelector(selectExecutionContex filters: res.filters, })); -const selectDatasourceMap = (state: LensState, datasourceMap: DatasourceMap) => datasourceMap; +const selectInjectedDependencies = (_state: LensState, dependencies: unknown) => dependencies; -const selectVisualizationMap = ( - state: LensState, - datasourceMap: DatasourceMap, - visualizationMap: VisualizationMap -) => visualizationMap; +// use this type to cast selectInjectedDependencies to require whatever outside dependencies the selector needs +type SelectInjectedDependenciesFunction = (state: LensState, dependencies: T) => T; export const selectSavedObjectFormat = createSelector( [ @@ -59,8 +56,11 @@ export const selectSavedObjectFormat = createSelector( selectQuery, selectFilters, selectActiveDatasourceId, - selectDatasourceMap, - selectVisualizationMap, + selectInjectedDependencies as SelectInjectedDependenciesFunction<{ + datasourceMap: DatasourceMap; + visualizationMap: VisualizationMap; + extractFilterReferences: FilterManager['extract']; + }>, ], ( persistedDoc, @@ -69,8 +69,7 @@ export const selectSavedObjectFormat = createSelector( query, filters, activeDatasourceId, - datasourceMap, - visualizationMap + { datasourceMap, visualizationMap, extractFilterReferences } ) => { const activeVisualization = visualization.state && visualization.activeId && visualizationMap[visualization.activeId]; @@ -101,7 +100,8 @@ export const selectSavedObjectFormat = createSelector( references.push(...savedObjectReferences); }); - const { persistableFilters, references: filterReferences } = extractFilterReferences(filters); + const { state: persistableFilters, references: filterReferences } = + extractFilterReferences(filters); references.push(...filterReferences); @@ -140,12 +140,19 @@ export const selectAreDatasourcesLoaded = createSelector( ); export const selectDatasourceLayers = createSelector( - [selectDatasourceStates, selectDatasourceMap], + [ + selectDatasourceStates, + selectInjectedDependencies as SelectInjectedDependenciesFunction, + ], (datasourceStates, datasourceMap) => getDatasourceLayers(datasourceStates, datasourceMap) ); export const selectFramePublicAPI = createSelector( - [selectDatasourceStates, selectActiveData, selectDatasourceMap], + [ + selectDatasourceStates, + selectActiveData, + selectInjectedDependencies as SelectInjectedDependenciesFunction, + ], (datasourceStates, activeData, datasourceMap) => { return { datasourceLayers: getDatasourceLayers(datasourceStates, datasourceMap), diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 397878675536d..7ea5ff232607a 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -289,6 +289,15 @@ export interface Datasource { * Given the current state layer and a columnId will verify if the column configuration has errors */ isValidColumn: (state: T, layerId: string, columnId: string) => boolean; + /** + * Are these datasources equivalent? + */ + isEqual: ( + persistableState1: P, + references1: SavedObjectReference[], + persistableState2: P, + references2: SavedObjectReference[] + ) => boolean; } export interface DatasourceFixAction { diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts deleted file mode 100644 index 9ce405804bde1..0000000000000 --- a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import semverGte from 'semver/functions/gte'; -import { lensEmbeddableFactory } from './lens_embeddable_factory'; -import { migrations } from '../migrations/saved_object_migrations'; - -describe('saved object migrations and embeddable migrations', () => { - test('should have same versions registered (>7.13.0)', () => { - const savedObjectMigrationVersions = Object.keys(migrations).filter((version) => { - return semverGte(version, '7.13.1'); - }); - const embeddableMigrationVersions = lensEmbeddableFactory()?.migrations; - if (embeddableMigrationVersions) { - expect(savedObjectMigrationVersions.sort()).toEqual( - Object.keys(embeddableMigrationVersions).sort() - ); - } - }); -}); diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts deleted file mode 100644 index 0e79e342d4427..0000000000000 --- a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EmbeddableRegistryDefinition } from 'src/plugins/embeddable/server'; -import type { SerializableRecord } from '@kbn/utility-types'; -import { DOC_TYPE } from '../../common'; -import { - commonMakeReversePaletteAsCustom, - commonRemoveTimezoneDateHistogramParam, - commonRenameOperationsForFormula, - commonUpdateVisLayerType, -} from '../migrations/common_migrations'; -import { - LensDocShape713, - LensDocShape715, - LensDocShapePre712, - VisState716, - VisStatePre715, -} from '../migrations/types'; -import { extract, inject } from '../../common/embeddable_factory'; - -export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { - return { - id: DOC_TYPE, - migrations: { - // This migration is run in 7.13.1 for `by value` panels because the 7.13 release window was missed. - '7.13.1': (state) => { - const lensState = state as unknown as { attributes: LensDocShapePre712 }; - const migratedLensState = commonRenameOperationsForFormula(lensState.attributes); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - '7.14.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape713 }; - const migratedLensState = commonRemoveTimezoneDateHistogramParam(lensState.attributes); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - '7.15.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape715 }; - const migratedLensState = commonUpdateVisLayerType(lensState.attributes); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - '7.16.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape715 }; - const migratedLensState = commonMakeReversePaletteAsCustom(lensState.attributes); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - }, - extract, - inject, - }; -}; diff --git a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.test.ts b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.test.ts new file mode 100644 index 0000000000000..5f4c69593d270 --- /dev/null +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import semverGte from 'semver/functions/gte'; +import { makeLensEmbeddableFactory } from './make_lens_embeddable_factory'; +import { getAllMigrations } from '../migrations/saved_object_migrations'; +import { Filter } from '@kbn/es-query'; + +describe('embeddable migrations', () => { + test('should have all saved object migrations versions (>7.13.0)', () => { + const savedObjectMigrationVersions = Object.keys(getAllMigrations({})).filter((version) => { + return semverGte(version, '7.13.1'); + }); + const embeddableMigrationVersions = makeLensEmbeddableFactory({})()?.migrations; + if (embeddableMigrationVersions) { + expect(savedObjectMigrationVersions.sort()).toEqual( + Object.keys(embeddableMigrationVersions).sort() + ); + } + }); + + test('should properly apply a filter migration within a lens visualization', () => { + const migrationVersion = 'some-version'; + + const lensVisualizationDoc = { + attributes: { + state: { + filters: [ + { + filter: 1, + migrated: false, + }, + { + filter: 2, + migrated: false, + }, + ], + }, + }, + }; + + const embeddableMigrationVersions = makeLensEmbeddableFactory({ + [migrationVersion]: (filters: Filter[]) => { + return filters.map((filterState) => ({ + ...filterState, + migrated: true, + })); + }, + })()?.migrations; + + const migratedLensDoc = embeddableMigrationVersions?.[migrationVersion](lensVisualizationDoc); + + expect(migratedLensDoc).toEqual({ + attributes: { + state: { + filters: [ + { + filter: 1, + migrated: true, + }, + { + filter: 2, + migrated: true, + }, + ], + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts new file mode 100644 index 0000000000000..f516a99b078f2 --- /dev/null +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EmbeddableRegistryDefinition } from 'src/plugins/embeddable/server'; +import type { SerializableRecord } from '@kbn/utility-types'; +import { + mergeMigrationFunctionMaps, + MigrateFunctionsObject, +} from '../../../../../src/plugins/kibana_utils/common'; +import { DOC_TYPE } from '../../common'; +import { + commonMakeReversePaletteAsCustom, + commonRemoveTimezoneDateHistogramParam, + commonRenameFilterReferences, + commonRenameOperationsForFormula, + commonUpdateVisLayerType, + getLensFilterMigrations, +} from '../migrations/common_migrations'; +import { + LensDocShape713, + LensDocShape715, + LensDocShapePre712, + VisState716, + VisStatePre715, +} from '../migrations/types'; +import { extract, inject } from '../../common/embeddable_factory'; + +export const makeLensEmbeddableFactory = + (filterMigrations: MigrateFunctionsObject) => (): EmbeddableRegistryDefinition => { + return { + id: DOC_TYPE, + migrations: mergeMigrationFunctionMaps(getLensFilterMigrations(filterMigrations), { + // This migration is run in 7.13.1 for `by value` panels because the 7.13 release window was missed. + '7.13.1': (state) => { + const lensState = state as unknown as { attributes: LensDocShapePre712 }; + const migratedLensState = commonRenameOperationsForFormula(lensState.attributes); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + '7.14.0': (state) => { + const lensState = state as unknown as { attributes: LensDocShape713 }; + const migratedLensState = commonRemoveTimezoneDateHistogramParam(lensState.attributes); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + '7.15.0': (state) => { + const lensState = state as unknown as { attributes: LensDocShape715 }; + const migratedLensState = commonUpdateVisLayerType(lensState.attributes); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + '7.16.0': (state) => { + const lensState = state as unknown as { attributes: LensDocShape715 }; + const migratedLensState = commonMakeReversePaletteAsCustom(lensState.attributes); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + '8.1.0': (state) => { + const lensState = state as unknown as { attributes: LensDocShape715 }; + const migratedLensState = commonRenameFilterReferences(lensState.attributes); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + }), + extract, + inject, + }; + }; diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.test.ts b/x-pack/plugins/lens/server/migrations/common_migrations.test.ts new file mode 100644 index 0000000000000..55c7bc641a04e --- /dev/null +++ b/x-pack/plugins/lens/server/migrations/common_migrations.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Filter } from '@kbn/es-query'; +import { getLensFilterMigrations } from './common_migrations'; + +describe('Lens migrations', () => { + describe('applying filter migrations', () => { + it('creates a filter migrations map that works on a lens visualization', () => { + const filterMigrations = { + '1.1': (filters: Filter[]) => filters.map((filter) => ({ ...filter, version: '1.1' })), + '2.2': (filters: Filter[]) => filters.map((filter) => ({ ...filter, version: '2.2' })), + '3.3': (filters: Filter[]) => filters.map((filter) => ({ ...filter, version: '3.3' })), + }; + + const lensVisualizationSavedObject = { + attributes: { + state: { + filters: [{}, {}], + }, + }, + }; + + const migrationMap = getLensFilterMigrations(filterMigrations); + + expect(migrationMap['1.1'](lensVisualizationSavedObject).attributes.state.filters).toEqual([ + { version: '1.1' }, + { version: '1.1' }, + ]); + expect(migrationMap['2.2'](lensVisualizationSavedObject).attributes.state.filters).toEqual([ + { version: '2.2' }, + { version: '2.2' }, + ]); + expect(migrationMap['3.3'](lensVisualizationSavedObject).attributes.state.filters).toEqual([ + { version: '3.3' }, + { version: '3.3' }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index 290655ec634eb..82cde025e31ed 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -7,6 +7,11 @@ import { cloneDeep } from 'lodash'; import { PaletteOutput } from 'src/plugins/charts/common'; +import { Filter } from '@kbn/es-query'; +import { + MigrateFunction, + MigrateFunctionsObject, +} from '../../../../../src/plugins/kibana_utils/common'; import { LensDocShapePre712, OperationTypePre712, @@ -19,6 +24,7 @@ import { VisState716, } from './types'; import { CustomPaletteParams, layerTypes } from '../../common'; +import { LensDocShape } from './saved_object_migrations'; export const commonRenameOperationsForFormula = ( attributes: LensDocShapePre712 @@ -155,3 +161,40 @@ export const commonMakeReversePaletteAsCustom = ( } return newAttributes; }; + +export const commonRenameFilterReferences = (attributes: LensDocShape715) => { + const newAttributes = cloneDeep(attributes); + for (const filter of newAttributes.state.filters) { + filter.meta.index = filter.meta.indexRefName; + delete filter.meta.indexRefName; + } + return newAttributes; +}; + +const getApplyFilterMigrationToLens = (filterMigration: MigrateFunction) => { + return (savedObject: { attributes: LensDocShape }) => { + return { + ...savedObject, + attributes: { + ...savedObject.attributes, + state: { + ...savedObject.attributes.state, + filters: filterMigration(savedObject.attributes.state.filters as unknown as Filter[]), + }, + }, + }; + }; +}; + +/** + * This creates a migration map that applies filter migrations to Lens visualizations + */ +export const getLensFilterMigrations = (filterMigrations: MigrateFunctionsObject) => { + const migrationMap: MigrateFunctionsObject = {}; + for (const version in filterMigrations) { + if (filterMigrations.hasOwnProperty(version)) { + migrationMap[version] = getApplyFilterMigrationToLens(filterMigrations[version]); + } + } + return migrationMap; +}; diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index ef67c768a8997..9a6407ae30552 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -6,7 +6,7 @@ */ import { cloneDeep } from 'lodash'; -import { migrations, LensDocShape } from './saved_object_migrations'; +import { getAllMigrations, LensDocShape } from './saved_object_migrations'; import { SavedObjectMigrationContext, SavedObjectMigrationFn, @@ -15,8 +15,10 @@ import { import { LensDocShape715, VisState716, VisStatePost715, VisStatePre715 } from './types'; import { CustomPaletteParams, layerTypes } from '../../common'; import { PaletteOutput } from 'src/plugins/charts/common'; +import { Filter } from '@kbn/es-query'; describe('Lens migrations', () => { + const migrations = getAllMigrations({}); describe('7.7.0 missing dimensions in XY', () => { const context = {} as SavedObjectMigrationContext; @@ -1404,4 +1406,162 @@ describe('Lens migrations', () => { ]); }); }); + + describe('8.1.0 update filter reference schema', () => { + const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const example = { + type: 'lens', + id: 'mocked-saved-object-id', + attributes: { + savedObjectId: '1', + title: 'MyRenamedOps', + description: '', + visualizationType: null, + state: { + datasourceMetaData: { + filterableIndexPatterns: [], + }, + datasourceStates: { + indexpattern: { + currentIndexPatternId: 'logstash-*', + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto', timeZone: 'Europe/Berlin' }, + }, + '4': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5': { + label: '@timestamp', + dataType: 'date', + operationType: 'my_unexpected_operation', + isBucketed: true, + scale: 'interval', + params: { timeZone: 'do not delete' }, + }, + }, + columnOrder: ['3', '4', '5'], + }, + }, + }, + }, + visualization: {}, + query: { query: '', language: 'kuery' }, + filters: [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'geo.src', + params: { query: 'US' }, + indexRefName: 'filter-index-pattern-0', + }, + query: { match_phrase: { 'geo.src': 'US' } }, + $state: { store: 'appState' }, + }, + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'client_ip', + params: { query: '1234.5344.2243.3245' }, + indexRefName: 'filter-index-pattern-2', + }, + query: { match_phrase: { client_ip: '1234.5344.2243.3245' } }, + $state: { store: 'appState' }, + }, + ], + }, + }, + } as unknown as SavedObjectUnsanitizedDoc>; + + it('should rename indexRefName to index in filters metadata', () => { + const expectedFilters = example.attributes.state.filters.map((filter) => { + return { + ...filter, + meta: { + ...filter.meta, + index: filter.meta.indexRefName, + indexRefName: undefined, + }, + }; + }); + + const result = migrations['8.1.0'](example, context) as ReturnType< + SavedObjectMigrationFn + >; + + expect(result.attributes.state.filters).toEqual(expectedFilters); + }); + }); + + test('should properly apply a filter migration within a lens visualization', () => { + const migrationVersion = 'some-version'; + + const lensVisualizationDoc = { + attributes: { + state: { + filters: [ + { + filter: 1, + migrated: false, + }, + { + filter: 2, + migrated: false, + }, + ], + }, + }, + }; + + const migrationFunctionsObject = getAllMigrations({ + [migrationVersion]: (filters: Filter[]) => { + return filters.map((filterState) => ({ + ...filterState, + migrated: true, + })); + }, + }); + + const migratedLensDoc = migrationFunctionsObject[migrationVersion]( + lensVisualizationDoc as SavedObjectUnsanitizedDoc, + {} as SavedObjectMigrationContext + ); + + expect(migratedLensDoc).toEqual({ + attributes: { + state: { + filters: [ + { + filter: 1, + migrated: true, + }, + { + filter: 2, + migrated: true, + }, + ], + }, + }, + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index 152ed173f8903..596ddf12ae7c3 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -5,16 +5,18 @@ * 2.0. */ -import { cloneDeep } from 'lodash'; +import { cloneDeep, mergeWith } from 'lodash'; import { fromExpression, toExpression, Ast, ExpressionFunctionAST } from '@kbn/interpreter'; import { SavedObjectMigrationMap, SavedObjectMigrationFn, SavedObjectReference, SavedObjectUnsanitizedDoc, + SavedObjectMigrationContext, } from 'src/core/server'; import { Filter } from '@kbn/es-query'; import { Query } from 'src/plugins/data/public'; +import { MigrateFunctionsObject } from '../../../../../src/plugins/kibana_utils/common'; import { PersistableFilter } from '../../common'; import { LensDocShapePost712, @@ -31,6 +33,8 @@ import { commonRemoveTimezoneDateHistogramParam, commonUpdateVisLayerType, commonMakeReversePaletteAsCustom, + commonRenameFilterReferences, + getLensFilterMigrations, } from './common_migrations'; interface LensDocShapePre710 { @@ -440,7 +444,15 @@ const moveDefaultReversedPaletteToCustom: SavedObjectMigrationFn< return { ...newDoc, attributes: commonMakeReversePaletteAsCustom(newDoc.attributes) }; }; -export const migrations: SavedObjectMigrationMap = { +const renameFilterReferences: SavedObjectMigrationFn< + LensDocShape715, + LensDocShape715 +> = (doc) => { + const newDoc = cloneDeep(doc); + return { ...newDoc, attributes: commonRenameFilterReferences(newDoc.attributes) }; +}; + +const lensMigrations: 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). @@ -453,4 +465,25 @@ export const migrations: SavedObjectMigrationMap = { '7.14.0': removeTimezoneDateHistogramParam, '7.15.0': addLayerTypeToVisualization, '7.16.0': moveDefaultReversedPaletteToCustom, + '8.1.0': renameFilterReferences, }; + +export const mergeSavedObjectMigrationMaps = ( + obj1: SavedObjectMigrationMap, + obj2: SavedObjectMigrationMap +): SavedObjectMigrationMap => { + const customizer = (objValue: SavedObjectMigrationFn, srcValue: SavedObjectMigrationFn) => { + if (!srcValue || !objValue) { + return srcValue || objValue; + } + return (state: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => + objValue(srcValue(state, context), context); + }; + + return mergeWith({ ...obj1 }, obj2, customizer); +}; + +export const getAllMigrations = ( + filterMigrations: MigrateFunctionsObject +): SavedObjectMigrationMap => + mergeSavedObjectMigrationMaps(lensMigrations, getLensFilterMigrations(filterMigrations)); diff --git a/x-pack/plugins/lens/server/migrations/types.ts b/x-pack/plugins/lens/server/migrations/types.ts index 43a0f5524f619..de643f9234156 100644 --- a/x-pack/plugins/lens/server/migrations/types.ts +++ b/x-pack/plugins/lens/server/migrations/types.ts @@ -8,7 +8,7 @@ import type { PaletteOutput } from 'src/plugins/charts/common'; import { Filter } from '@kbn/es-query'; import { Query } from 'src/plugins/data/public'; -import type { CustomPaletteParams, LayerType } from '../../common'; +import type { CustomPaletteParams, LayerType, PersistableFilter } from '../../common'; export type OperationTypePre712 = | 'avg' @@ -191,10 +191,17 @@ export interface LensDocShape715 { }; visualization: VisualizationState; query: Query; - filters: Filter[]; + filters: PersistableFilter[]; }; } +export type LensDocShape810 = Omit< + LensDocShape715, + 'filters' +> & { + filters: Filter[]; +}; + export type VisState716 = // Datatable | { diff --git a/x-pack/plugins/lens/server/plugin.tsx b/x-pack/plugins/lens/server/plugin.tsx index 42e68c6223b6d..3f0a41efc21c7 100644 --- a/x-pack/plugins/lens/server/plugin.tsx +++ b/x-pack/plugins/lens/server/plugin.tsx @@ -7,7 +7,10 @@ import { Plugin, CoreSetup, CoreStart, PluginInitializerContext, Logger } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { PluginStart as DataPluginStart } from 'src/plugins/data/server'; +import { + PluginStart as DataPluginStart, + PluginSetup as DataPluginSetup, +} from 'src/plugins/data/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { FieldFormatsStart } from 'src/plugins/field_formats/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; @@ -19,14 +22,15 @@ import { } from './usage'; import { setupSavedObjects } from './saved_objects'; import { EmbeddableSetup } from '../../../../src/plugins/embeddable/server'; -import { lensEmbeddableFactory } from './embeddable/lens_embeddable_factory'; import { setupExpressions } from './expressions'; +import { makeLensEmbeddableFactory } from './embeddable/make_lens_embeddable_factory'; export interface PluginSetupContract { usageCollection?: UsageCollectionSetup; taskManager?: TaskManagerSetupContract; embeddable: EmbeddableSetup; expressions: ExpressionsServerSetup; + data: DataPluginSetup; } export interface PluginStartContract { @@ -36,7 +40,7 @@ export interface PluginStartContract { } export interface LensServerPluginSetup { - lensEmbeddableFactory: typeof lensEmbeddableFactory; + lensEmbeddableFactory: ReturnType; } export class LensServerPlugin implements Plugin { @@ -47,7 +51,8 @@ export class LensServerPlugin implements Plugin, plugins: PluginSetupContract) { - setupSavedObjects(core); + const filterMigrations = plugins.data.query.filterManager.getAllMigrations(); + setupSavedObjects(core, filterMigrations); setupRoutes(core, this.initializerContext.logger.get()); setupExpressions(core, plugins.expressions); @@ -61,6 +66,7 @@ export class LensServerPlugin implements Plugin