diff --git a/src/plugins/data/common/query/filters/persistable_state.test.ts b/src/plugins/data/common/query/filters/persistable_state.test.ts index 9ce3e2536a70a..392b029d8c7c2 100644 --- a/src/plugins/data/common/query/filters/persistable_state.test.ts +++ b/src/plugins/data/common/query/filters/persistable_state.test.ts @@ -26,7 +26,7 @@ describe('filter manager persistable state tests', () => { const updatedFilters = inject(filters, [ { type: DATA_VIEW_SAVED_OBJECT_TYPE, name: 'test123', id: '123' }, ]); - expect(updatedFilters[0]).toHaveProperty('meta.index', undefined); + expect(updatedFilters[0]).toHaveProperty('meta.index', 'test'); }); }); diff --git a/src/plugins/data/common/query/filters/persistable_state.ts b/src/plugins/data/common/query/filters/persistable_state.ts index a309573fb9df2..a2696723fbab7 100644 --- a/src/plugins/data/common/query/filters/persistable_state.ts +++ b/src/plugins/data/common/query/filters/persistable_state.ts @@ -46,7 +46,8 @@ export const inject = (filters: Filter[], references: SavedObjectReference[]) => ...filter, meta: { ...filter.meta, - index: reference && reference.id, + // if no reference has been found, keep the current "index" property (used for adhoc data views) + index: reference ? reference.id : filter.meta.index, }, }; }); diff --git a/src/plugins/data/common/query/persistable_state.test.ts b/src/plugins/data/common/query/persistable_state.test.ts index 2fcfce910ebb8..f44e5276ca7fe 100644 --- a/src/plugins/data/common/query/persistable_state.test.ts +++ b/src/plugins/data/common/query/persistable_state.test.ts @@ -41,7 +41,7 @@ describe('query service persistable state tests', () => { const updatedQueryState = inject(queryState, [ { type: DATA_VIEW_SAVED_OBJECT_TYPE, name: 'test123', id: '123' }, ]); - expect(updatedQueryState.filters[0]).toHaveProperty('meta.index', undefined); + expect(updatedQueryState.filters[0]).toHaveProperty('meta.index', 'test'); }); }); diff --git a/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx b/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx index 629e7b499a5f0..74cf563f6fcdb 100644 --- a/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx +++ b/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx @@ -60,6 +60,7 @@ export const TextBasedLanguagesList = (props: TextBasedLanguagesListProps) => ( export function ChangeDataView({ isMissingCurrent, currentDataViewId, + adHocDataViews, onChangeDataView, onAddField, onDataViewCreated, @@ -93,10 +94,21 @@ export function ChangeDataView({ useEffect(() => { const fetchDataViews = async () => { const dataViewsRefs = await data.dataViews.getIdsWithTitle(); + if (adHocDataViews?.length) { + adHocDataViews.forEach((adHocDataView) => { + if (adHocDataView.id) { + dataViewsRefs.push({ + title: adHocDataView.title, + name: adHocDataView.name, + id: adHocDataView.id, + }); + } + }); + } setDataViewsList(dataViewsRefs); }; fetchDataViews(); - }, [data, currentDataViewId]); + }, [data, currentDataViewId, adHocDataViews]); useEffect(() => { if (trigger.label) { diff --git a/src/plugins/unified_search/public/dataview_picker/index.tsx b/src/plugins/unified_search/public/dataview_picker/index.tsx index 82909714cffad..1a6f3e569d8bb 100644 --- a/src/plugins/unified_search/public/dataview_picker/index.tsx +++ b/src/plugins/unified_search/public/dataview_picker/index.tsx @@ -8,6 +8,7 @@ import React from 'react'; import type { EuiButtonProps, EuiSelectableProps } from '@elastic/eui'; +import type { DataView } from '@kbn/data-views-plugin/public'; import type { AggregateQuery, Query } from '@kbn/es-query'; import { ChangeDataView } from './change_dataview'; @@ -44,6 +45,10 @@ export interface DataViewPickerProps { * The id of the selected dataview. */ currentDataViewId?: string; + /** + * The adHocDataviews. + */ + adHocDataViews?: DataView[]; /** * EuiSelectable properties. */ @@ -84,6 +89,7 @@ export interface DataViewPickerPropsExtended extends DataViewPickerProps { export const DataViewPicker = ({ isMissingCurrent, currentDataViewId, + adHocDataViews, onChangeDataView, onAddField, onDataViewCreated, @@ -102,6 +108,7 @@ export const DataViewPicker = ({ onAddField={onAddField} onDataViewCreated={onDataViewCreated} trigger={trigger} + adHocDataViews={adHocDataViews} selectableProps={selectableProps} textBasedLanguages={textBasedLanguages} onSaveTextLanguageQuery={onSaveTextLanguageQuery} diff --git a/test/functional/page_objects/unified_search_page.ts b/test/functional/page_objects/unified_search_page.ts index ab85d250a61e3..be830defd57e2 100644 --- a/test/functional/page_objects/unified_search_page.ts +++ b/test/functional/page_objects/unified_search_page.ts @@ -11,6 +11,7 @@ import { FtrService } from '../ftr_provider_context'; export class UnifiedSearchPageObject extends FtrService { private readonly retry = this.ctx.getService('retry'); private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly find = this.ctx.getService('find'); public async switchDataView(switchButtonSelector: string, dataViewTitle: string) { await this.testSubjects.click(switchButtonSelector); @@ -35,4 +36,28 @@ export class UnifiedSearchPageObject extends FtrService { return visibleText; } + + public async clickCreateNewDataView() { + await this.retry.waitForWithTimeout('data create new to be visible', 15000, async () => { + return await this.testSubjects.isDisplayed('dataview-create-new'); + }); + await this.testSubjects.click('dataview-create-new'); + await this.retry.waitForWithTimeout( + 'index pattern editor form to be visible', + 15000, + async () => { + return await (await this.find.byClassName('indexPatternEditor__form')).isDisplayed(); + } + ); + await (await this.find.byClassName('indexPatternEditor__form')).click(); + } + + public async createNewDataView(dataViewName: string, adHoc?: boolean) { + await this.clickCreateNewDataView(); + await this.testSubjects.setValue('createIndexPatternTitleInput', dataViewName, { + clearWithKeyboard: true, + typeCharByChar: true, + }); + await this.testSubjects.click(adHoc ? 'exploreIndexPatternButton' : 'saveIndexPatternButton'); + } } diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 1587a751b7c08..563b63b279935 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -1033,7 +1033,11 @@ describe('common utils', () => { ].join('\n\n'); const extractedReferences = extractLensReferencesFromCommentString( - makeLensEmbeddableFactory(() => ({}), {}), + makeLensEmbeddableFactory( + () => ({}), + () => ({}), + {} + ), commentString ); @@ -1132,7 +1136,11 @@ describe('common utils', () => { ].join('\n\n'); const updatedReferences = getOrUpdateLensReferences( - makeLensEmbeddableFactory(() => ({}), {}), + makeLensEmbeddableFactory( + () => ({}), + () => ({}), + {} + ), newCommentString, { references: currentCommentReferences, 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 ae474fa7b13f9..5804c0ff401dd 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 @@ -237,7 +237,11 @@ describe('comments migrations', () => { it('should remove time zone param from date histogram', () => { const migrations = createCommentsMigrations({ persistableStateAttachmentTypeRegistry: new PersistableStateAttachmentTypeRegistry(), - lensEmbeddableFactory: makeLensEmbeddableFactory(() => ({}), {}), + lensEmbeddableFactory: makeLensEmbeddableFactory( + () => ({}), + () => ({}), + {} + ), }); expect(migrations['7.14.0']).toBeDefined(); @@ -574,7 +578,11 @@ describe('comments migrations', () => { const migrations = createCommentsMigrations({ persistableStateAttachmentTypeRegistry, - lensEmbeddableFactory: makeLensEmbeddableFactory(() => ({}), {}), + lensEmbeddableFactory: makeLensEmbeddableFactory( + () => ({}), + () => ({}), + {} + ), }); it('migrates a persistable state attachment correctly', () => { diff --git a/x-pack/plugins/lens/public/app_plugin/__snapshots__/app.test.tsx.snap b/x-pack/plugins/lens/public/app_plugin/__snapshots__/app.test.tsx.snap index 9f65fa549e03c..4357609d0701d 100644 --- a/x-pack/plugins/lens/public/app_plugin/__snapshots__/app.test.tsx.snap +++ b/x-pack/plugins/lens/public/app_plugin/__snapshots__/app.test.tsx.snap @@ -10,6 +10,7 @@ Array [ "loadIndexPatternRefs": [Function], "loadIndexPatterns": [Function], "refreshExistingFields": [Function], + "replaceDataViewId": [Function], "updateDataViewsState": [Function], }, "lensInspector": Object { 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 7f166cb54e299..09cca5520f644 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -146,7 +146,7 @@ describe('Lens App', () => { it('updates global filters with store state', async () => { const services = makeDefaultServicesForApp(); - const indexPattern = { id: 'index1' } as unknown as DataView; + const indexPattern = { id: 'index1', isPersisted: () => true } as unknown as DataView; const pinnedField = { name: 'pinnedField' } as unknown as FieldSpec; const pinnedFilter = buildExistsFilter(pinnedField, indexPattern); services.data.query.filterManager.getFilters = jest.fn().mockImplementation(() => { @@ -348,7 +348,9 @@ describe('Lens App', () => { const customServices = makeDefaultServicesForApp(); customServices.dataViews.get = jest .fn() - .mockImplementation((id) => Promise.resolve({ id, isTimeBased: () => true } as DataView)); + .mockImplementation((id) => + Promise.resolve({ id, isTimeBased: () => true, isPersisted: () => true } as DataView) + ); const { services } = await mountWith({ services: customServices }); expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ showDatePicker: true }), @@ -359,7 +361,9 @@ describe('Lens App', () => { const customServices = makeDefaultServicesForApp(); customServices.dataViews.get = jest .fn() - .mockImplementation((id) => Promise.resolve({ id, isTimeBased: () => true } as DataView)); + .mockImplementation((id) => + Promise.resolve({ id, isTimeBased: () => true, isPersisted: () => true } as DataView) + ); const customProps = makeDefaultProps(); customProps.datasourceMap.testDatasource.isTimeBased = () => true; const { services } = await mountWith({ props: customProps, services: customServices }); @@ -372,7 +376,9 @@ describe('Lens App', () => { const customServices = makeDefaultServicesForApp(); customServices.dataViews.get = jest .fn() - .mockImplementation((id) => Promise.resolve({ id, isTimeBased: () => true } as DataView)); + .mockImplementation((id) => + Promise.resolve({ id, isTimeBased: () => true, isPersisted: () => true } as DataView) + ); const customProps = makeDefaultProps(); customProps.datasourceMap.testDatasource.isTimeBased = () => false; const { services } = await mountWith({ props: customProps, services: customServices }); @@ -477,7 +483,14 @@ describe('Lens App', () => { expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith( expect.objectContaining({ query: 'fake query', - indexPatterns: [{ id: 'mockip', isTimeBased: expect.any(Function), fields: [] }], + indexPatterns: [ + { + id: 'mockip', + isTimeBased: expect.any(Function), + fields: [], + isPersisted: expect.any(Function), + }, + ], }), {} ); @@ -823,7 +836,7 @@ describe('Lens App', () => { }); it('saves app filters and does not save pinned filters', async () => { - const indexPattern = { id: 'index1' } as unknown as DataView; + const indexPattern = { id: 'index1', isPersisted: () => true } as unknown as DataView; const field = { name: 'myfield' } as unknown as FieldSpec; const pinnedField = { name: 'pinnedField' } as unknown as FieldSpec; const unpinned = buildExistsFilter(field, indexPattern); @@ -1032,7 +1045,7 @@ describe('Lens App', () => { it('updates the filters when the user changes them', async () => { const { instance, services, lensStore } = await mountWith({}); - const indexPattern = { id: 'index1' } as unknown as DataView; + const indexPattern = { id: 'index1', isPersisted: () => true } as unknown as DataView; const field = { name: 'myfield' } as unknown as FieldSpec; expect(lensStore.getState()).toEqual({ lens: expect.objectContaining({ @@ -1085,7 +1098,7 @@ describe('Lens App', () => { searchSessionId: `sessionId-3`, }), }); - const indexPattern = { id: 'index1' } as unknown as DataView; + const indexPattern = { id: 'index1', isPersisted: () => true } as unknown as DataView; const field = { name: 'myfield' } as unknown as FieldSpec; act(() => services.data.query.filterManager.setFilters([buildExistsFilter(field, indexPattern)]) @@ -1218,7 +1231,7 @@ describe('Lens App', () => { query: { query: 'new', language: 'lucene' }, }) ); - const indexPattern = { id: 'index1' } as unknown as DataView; + const indexPattern = { id: 'index1', isPersisted: () => true } as unknown as DataView; const field = { name: 'myfield' } as unknown as FieldSpec; const pinnedField = { name: 'pinnedField' } as unknown as FieldSpec; const unpinned = buildExistsFilter(field, indexPattern); @@ -1275,7 +1288,7 @@ describe('Lens App', () => { query: { query: 'new', language: 'lucene' }, }) ); - const indexPattern = { id: 'index1' } as unknown as DataView; + const indexPattern = { id: 'index1', isPersisted: () => true } as unknown as DataView; const field = { name: 'myfield' } as unknown as FieldSpec; const pinnedField = { name: 'pinnedField' } as unknown as FieldSpec; const unpinned = buildExistsFilter(field, indexPattern); diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 7ff105adf43f7..da8549fef03e4 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -32,6 +32,7 @@ import { LensInspector } from '../lens_inspector_service'; import { getEditPath } from '../../common'; import { isLensEqual } from './lens_document_equality'; import { IndexPatternServiceAPI, createIndexPatternService } from '../indexpattern_service/service'; +import { replaceIndexpattern } from '../state_management/lens_slice'; export type SaveProps = Omit & { returnToOrigin: boolean; @@ -368,6 +369,7 @@ export function App({ createIndexPatternService({ dataViews: lensAppServices.dataViews, uiSettings: lensAppServices.uiSettings, + uiActions: lensAppServices.uiActions, core: { http, notifications }, updateIndexPatterns: (newIndexPatternsState, options) => { dispatch(updateIndexPatterns(newIndexPatternsState)); @@ -375,6 +377,12 @@ export function App({ dispatch(applyChanges()); } }, + replaceIndexPattern: (newIndexPattern, oldId, options) => { + dispatch(replaceIndexpattern({ newIndexPattern, oldId })); + if (options?.applyImmediately) { + dispatch(applyChanges()); + } + }, }), [dispatch, http, notifications, lensAppServices] ); 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 index 879713dbd7b3c..dae571acf10ed 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_document_equality.ts +++ b/x-pack/plugins/lens/public/app_plugin/lens_document_equality.ts @@ -54,9 +54,9 @@ export const isLensEqual = ( .map((type) => datasourceMap[type].isEqual( doc1.state.datasourceStates[type], - doc1.references, + [...doc1.references, ...(doc1.state.internalReferences || [])], doc2.state.datasourceStates[type], - doc2.references + [...doc2.references, ...(doc2.state.internalReferences || [])] ) ) .every((res) => res); diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index 88643b52aab77..582ee5327b1a7 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -251,23 +251,20 @@ export const LensTopNavMenu = ({ (state: Partial) => dispatch(setState(state)), [dispatch] ); + const [indexPatterns, setIndexPatterns] = useState([]); + const [currentIndexPattern, setCurrentIndexPattern] = useState(); + const [rejectedIndexPatterns, setRejectedIndexPatterns] = useState([]); + const dispatchChangeIndexPattern = React.useCallback( - async (indexPatternId) => { - const [newIndexPatternRefs, newIndexPatterns] = await Promise.all([ - // Reload refs in case it's a new indexPattern created on the spot - dataViews.indexPatternRefs[indexPatternId] - ? dataViews.indexPatternRefs - : indexPatternService.loadIndexPatternRefs({ - isFullEditor: true, - }), - indexPatternService.ensureIndexPattern({ - id: indexPatternId, - cache: dataViews.indexPatterns, - }), - ]); + async (dataViewOrId: DataView | string) => { + const indexPatternId = typeof dataViewOrId === 'string' ? dataViewOrId : dataViewOrId.id!; + const newIndexPatterns = await indexPatternService.ensureIndexPattern({ + id: indexPatternId, + cache: dataViews.indexPatterns, + }); dispatch( changeIndexPattern({ - dataViews: { indexPatterns: newIndexPatterns, indexPatternRefs: newIndexPatternRefs }, + dataViews: { indexPatterns: newIndexPatterns }, datasourceIds: Object.keys(datasourceStates), visualizationIds: visualization.activeId ? [visualization.activeId] : [], indexPatternId, @@ -275,7 +272,6 @@ export const LensTopNavMenu = ({ ); }, [ - dataViews.indexPatternRefs, dataViews.indexPatterns, datasourceStates, dispatch, @@ -284,9 +280,6 @@ export const LensTopNavMenu = ({ ] ); - const [indexPatterns, setIndexPatterns] = useState([]); - const [currentIndexPattern, setCurrentIndexPattern] = useState(); - const [rejectedIndexPatterns, setRejectedIndexPatterns] = useState([]); const canEditDataView = Boolean(dataViewEditor?.userPermissions.editDataView()); const closeFieldEditor = useRef<() => void | undefined>(); const closeDataViewEditor = useRef<() => void | undefined>(); @@ -301,19 +294,28 @@ export const LensTopNavMenu = ({ if (!activeDatasource) { return; } - const indexPatternIds = getIndexPatternsIds({ - activeDatasources: Object.keys(datasourceStates).reduce( - (acc, datasourceId) => ({ - ...acc, - [datasourceId]: datasourceMap[datasourceId], - }), - {} - ), - datasourceStates, - }); + const indexPatternIds = new Set( + getIndexPatternsIds({ + activeDatasources: Object.keys(datasourceStates).reduce( + (acc, datasourceId) => ({ + ...acc, + [datasourceId]: datasourceMap[datasourceId], + }), + {} + ), + datasourceStates, + }) + ); + // Add ad-hoc data views from the Lens state even if they are not used + Object.values(dataViews.indexPatterns) + .filter((indexPattern) => indexPattern.spec) + .forEach((indexPattern) => { + indexPatternIds.add(indexPattern.id); + }); + const hasIndexPatternsChanged = - indexPatterns.length + rejectedIndexPatterns.length !== indexPatternIds.length || - indexPatternIds.some( + indexPatterns.length + rejectedIndexPatterns.length !== indexPatternIds.size || + [...indexPatternIds].some( (id) => ![...indexPatterns.map((ip) => ip.id), ...rejectedIndexPatterns].find( (loadedId) => loadedId === id @@ -322,7 +324,7 @@ export const LensTopNavMenu = ({ // Update the cached index patterns if the user made a change to any of them if (hasIndexPatternsChanged) { - getIndexPatternsObjects(indexPatternIds, dataViewsService).then( + getIndexPatternsObjects([...indexPatternIds], dataViewsService).then( ({ indexPatterns: indexPatternObjects, rejectedIds }) => { setIndexPatterns(indexPatternObjects); setRejectedIndexPatterns(rejectedIds); @@ -336,6 +338,7 @@ export const LensTopNavMenu = ({ datasourceMap, indexPatterns, dataViewsService, + dataViews, ]); useEffect(() => { @@ -675,8 +678,12 @@ export const LensTopNavMenu = ({ dataView: indexPatternInstance, }, fieldName, - onSave: async () => { - refreshFieldList(); + onSave: () => { + if (indexPatternInstance.isPersisted()) { + refreshFieldList(); + } else { + indexPatternService.replaceDataViewId(indexPatternInstance); + } }, }); } @@ -687,6 +694,7 @@ export const LensTopNavMenu = ({ currentIndexPattern?.id, data.dataViews, dataViewFieldEditor, + indexPatternService, refreshFieldList, ] ); @@ -703,14 +711,15 @@ export const LensTopNavMenu = ({ closeDataViewEditor.current = dataViewEditor.openEditor({ onSave: async (dataView) => { if (dataView.id) { - dispatchChangeIndexPattern(dataView.id); - refreshFieldList(); + dispatchChangeIndexPattern(dataView); + setCurrentIndexPattern(dataView); } }, + allowAdHocDataView: true, }); } : undefined, - [canEditDataView, dataViewEditor, dispatchChangeIndexPattern, refreshFieldList] + [canEditDataView, dataViewEditor, dispatchChangeIndexPattern] ); const dataViewPickerProps = { @@ -722,6 +731,7 @@ export const LensTopNavMenu = ({ currentDataViewId: currentIndexPattern?.id, onAddField: addField, onDataViewCreated: createNewDataView, + adHocDataViews: indexPatterns.filter((pattern) => !pattern.isPersisted()), onChangeDataView: (newIndexPatternId: string) => { const currentDataView = indexPatterns.find( (indexPattern) => indexPattern.id === newIndexPatternId diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 8c73d826724ea..65c77536078e5 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -86,6 +86,7 @@ export async function getLensServices( attributeService, executionContext: coreStart.executionContext, http: coreStart.http, + uiActions: startDependencies.uiActions, chrome: coreStart.chrome, overlays: coreStart.overlays, uiSettings: coreStart.uiSettings, diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index be49ca768acdc..65ceea382201c 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -32,7 +32,11 @@ import type { DashboardFeatureFlagConfig } from '@kbn/dashboard-plugin/public'; import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public'; import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; -import { VisualizeFieldContext, ACTION_VISUALIZE_LENS_FIELD } from '@kbn/ui-actions-plugin/public'; +import { + VisualizeFieldContext, + ACTION_VISUALIZE_LENS_FIELD, + UiActionsStart, +} from '@kbn/ui-actions-plugin/public'; import { ACTION_CONVERT_TO_LENS } from '@kbn/visualizations-plugin/public'; import type { EmbeddableEditorState, EmbeddableStateTransfer } from '@kbn/embeddable-plugin/public'; import type { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; @@ -130,6 +134,7 @@ export interface LensAppServices { data: DataPublicPluginStart; inspector: LensInspector; uiSettings: IUiSettingsClient; + uiActions: UiActionsStart; application: ApplicationStart; notifications: NotificationsStart; usageCollection?: UsageCollectionStart; 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 index cfb3e5d96dd9c..a5cc6c06644ff 100644 --- 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 @@ -10,8 +10,9 @@ import { Ast } from '@kbn/interpreter'; import memoizeOne from 'memoize-one'; import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; import { difference } from 'lodash'; -import type { DataViewsContract } from '@kbn/data-views-plugin/public'; +import type { DataViewsContract, DataViewSpec } from '@kbn/data-views-plugin/public'; import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; +import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common'; import { Datasource, DatasourceLayers, @@ -43,7 +44,8 @@ import { loadIndexPatternRefs, loadIndexPatterns } from '../../indexpattern_serv function getIndexPatterns( references?: SavedObjectReference[], initialContext?: VisualizeFieldContext | VisualizeEditorContext, - initialId?: string + initialId?: string, + adHocDataviews?: string[] ) { const indexPatternIds = []; if (initialContext) { @@ -67,6 +69,9 @@ function getIndexPatterns( } } } + if (adHocDataviews) { + indexPatternIds.push(...adHocDataviews); + } return [...new Set(indexPatternIds)]; } @@ -87,6 +92,7 @@ export async function initializeDataViews( defaultIndexPatternId, references, initialContext, + adHocDataViews: persistedAdHocDataViews, }: { dataViews: DataViewsContract; datasourceMap: DatasourceMap; @@ -95,13 +101,20 @@ export async function initializeDataViews( storage: IStorageWrapper; references?: SavedObjectReference[]; initialContext?: VisualizeFieldContext | VisualizeEditorContext; + adHocDataViews?: Record; }, options?: InitializationOptions ) { + const adHocDataViews = Object.fromEntries( + Object.entries(persistedAdHocDataViews || {}).map(([id, persistedSpec]) => { + const spec = DataViewPersistableStateService.inject(persistedSpec, references || []); + return [id, spec]; + }) + ); const { isFullEditor } = options ?? {}; // make it explicit or TS will infer never[] and break few lines down const indexPatternRefs: IndexPatternRef[] = await (isFullEditor - ? loadIndexPatternRefs(dataViews) + ? loadIndexPatternRefs(dataViews, adHocDataViews) : []); // if no state is available, use the fallbackId @@ -113,7 +126,14 @@ export async function initializeDataViews( ? fallbackId : undefined; - const usedIndexPatterns = getIndexPatterns(references, initialContext, initialId); + const adHocDataviewsIds: string[] = Object.keys(adHocDataViews || {}); + + const usedIndexPatterns = getIndexPatterns( + references, + initialContext, + initialId, + adHocDataviewsIds + ); // load them const availableIndexPatterns = new Set(indexPatternRefs.map(({ id }: IndexPatternRef) => id)); @@ -125,6 +145,7 @@ export async function initializeDataViews( patterns: usedIndexPatterns, notUsedPatterns, cache: {}, + adHocDataViews, }); return { indexPatternRefs, indexPatterns }; @@ -142,6 +163,7 @@ export async function initializeSources( defaultIndexPatternId, references, initialContext, + adHocDataViews, }: { dataViews: DataViewsContract; datasourceMap: DatasourceMap; @@ -150,6 +172,7 @@ export async function initializeSources( storage: IStorageWrapper; references?: SavedObjectReference[]; initialContext?: VisualizeFieldContext | VisualizeEditorContext; + adHocDataViews?: Record; }, options?: InitializationOptions ) { @@ -162,6 +185,7 @@ export async function initializeSources( storage, defaultIndexPatternId, references, + adHocDataViews, }, options ); @@ -246,7 +270,12 @@ export async function persistedStateToExpression( } ): Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }> { const { - state: { visualization: visualizationState, datasourceStates: persistedDatasourceStates }, + state: { + visualization: visualizationState, + datasourceStates: persistedDatasourceStates, + adHocDataViews, + internalReferences, + }, visualizationType, references, title, @@ -279,13 +308,14 @@ export async function persistedStateToExpression( dataViews: services.dataViews, storage: services.storage, defaultIndexPatternId: services.uiSettings.get('defaultIndex'), + adHocDataViews, }, { isFullEditor: false } ); const datasourceStates = initializeDatasources({ datasourceMap, datasourceStates: datasourceStatesFromSO, - references, + references: [...references, ...(internalReferences || [])], indexPatterns, indexPatternRefs, }); diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 6aa2c19de6705..d406003ff5cb5 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -55,6 +55,7 @@ import type { } from '@kbn/core/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import { BrushTriggerEvent, ClickTriggerEvent } from '@kbn/charts-plugin/public'; +import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common'; import { getExecutionContextEvents, trackUiCounterEvents } from '../lens_ui_telemetry'; import { Document } from '../persistence'; import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper'; @@ -770,8 +771,20 @@ export class Embeddable this.activeDataInfo.activeDatasource = this.deps.datasourceMap[activeDatasourceId]; const docDatasourceState = this.savedVis?.state.datasourceStates[activeDatasourceId]; + const adHocDataviews = await Promise.all( + Object.values(this.savedVis?.state.adHocDataViews || {}) + .map((persistedSpec) => { + return DataViewPersistableStateService.inject( + persistedSpec, + this.savedVis?.references || [] + ); + }) + .map((spec) => this.deps.dataViews.create(spec)) + ); + + const allIndexPatterns = [...this.indexPatterns, ...adHocDataviews]; - const indexPatternsCache = this.indexPatterns.reduce( + const indexPatternsCache = allIndexPatterns.reduce( (acc, indexPattern) => ({ [indexPattern.id!]: convertDataViewIntoLensIndexPattern(indexPattern), ...acc, @@ -782,7 +795,7 @@ export class Embeddable if (!this.activeDataInfo.activeDatasourceState) { this.activeDataInfo.activeDatasourceState = this.activeDataInfo.activeDatasource.initialize( docDatasourceState, - this.savedVis?.references, + [...(this.savedVis?.references || []), ...(this.savedVis?.state.internalReferences || [])], undefined, undefined, indexPatternsCache @@ -831,6 +844,13 @@ export class Embeddable this.savedVis?.references.map(({ id }) => id) || [], this.deps.dataViews ); + ( + await Promise.all( + Object.values(this.savedVis?.state.adHocDataViews || {}).map((spec) => + this.deps.dataViews.create(spec) + ) + ) + ).forEach((dataView) => indexPatterns.push(dataView)); this.indexPatterns = uniqBy(indexPatterns, 'id'); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx deleted file mode 100644 index 75ebbfdeb27a4..0000000000000 --- a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx +++ /dev/null @@ -1,93 +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 { i18n } from '@kbn/i18n'; -import React, { useState } from 'react'; -import { EuiPopover, EuiPopoverTitle, EuiSelectableProps } from '@elastic/eui'; -import { ToolbarButton, ToolbarButtonProps } from '@kbn/kibana-react-plugin/public'; -import { DataViewsList } from '@kbn/unified-search-plugin/public'; -import type { IndexPatternRef } from '../types'; - -export type ChangeIndexPatternTriggerProps = ToolbarButtonProps & { - label: string; - title?: string; -}; - -export function ChangeIndexPattern({ - indexPatternRefs, - isMissingCurrent, - indexPatternId, - onChangeIndexPattern, - trigger, - selectableProps, -}: { - trigger: ChangeIndexPatternTriggerProps; - indexPatternRefs: IndexPatternRef[]; - isMissingCurrent?: boolean; - onChangeIndexPattern: (newId: string) => void; - indexPatternId?: string; - selectableProps?: EuiSelectableProps; -}) { - const [isPopoverOpen, setPopoverIsOpen] = useState(false); - - // be careful to only add color with a value, otherwise it will fallbacks to "primary" - const colorProp = isMissingCurrent - ? { - color: 'danger' as const, - } - : {}; - - const createTrigger = function () { - const { label, title, ...rest } = trigger; - return ( - setPopoverIsOpen(!isPopoverOpen)} - fullWidth - {...colorProp} - {...rest} - > - {label} - - ); - }; - - return ( - <> - setPopoverIsOpen(false)} - display="block" - panelPaddingSize="none" - ownFocus - > -
- - {i18n.translate('xpack.lens.indexPattern.changeDataViewTitle', { - defaultMessage: 'Data view', - })} - - - { - onChangeIndexPattern(newId); - setPopoverIsOpen(false); - }} - currentDataViewId={indexPatternId} - selectableProps={selectableProps} - /> -
-
- - ); -} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 300a96fb846c5..26158c42742e0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -307,9 +307,15 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ const currentIndexPattern = indexPatterns[currentIndexPatternId]; const existingFieldsForIndexPattern = existingFields[currentIndexPattern?.title]; const visualizeGeoFieldTrigger = uiActions.getTrigger(VISUALIZE_GEO_FIELD_TRIGGER); - const allFields = visualizeGeoFieldTrigger - ? currentIndexPattern.fields - : currentIndexPattern.fields.filter(({ type }) => type !== 'geo_point' && type !== 'geo_shape'); + const allFields = useMemo( + () => + visualizeGeoFieldTrigger && !currentIndexPattern.spec + ? currentIndexPattern.fields + : currentIndexPattern.fields.filter( + ({ type }) => type !== 'geo_point' && type !== 'geo_shape' + ), + [currentIndexPattern.fields, currentIndexPattern.spec, visualizeGeoFieldTrigger] + ); const clearLocalState = () => setLocalState((s) => ({ ...s, nameFilter: '', typeFilter: [] })); const availableFieldTypes = uniq(allFields.map(({ type }) => type)).filter( (type) => type in fieldTypeNames @@ -523,11 +529,24 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ dataView: indexPatternInstance, }, fieldName, - onSave: () => refreshFieldList(), + onSave: () => { + if (indexPatternInstance.isPersisted()) { + refreshFieldList(); + } else { + indexPatternService.replaceDataViewId(indexPatternInstance); + } + }, }); } : undefined, - [editPermission, dataViews, currentIndexPattern.id, indexPatternFieldEditor, refreshFieldList] + [ + editPermission, + dataViews, + currentIndexPattern.id, + indexPatternFieldEditor, + refreshFieldList, + indexPatternService, + ] ); const removeField = useMemo( @@ -540,11 +559,24 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ dataView: indexPatternInstance, }, fieldName, - onDelete: () => refreshFieldList(), + onDelete: () => { + if (indexPatternInstance.isPersisted()) { + refreshFieldList(); + } else { + indexPatternService.replaceDataViewId(indexPatternInstance); + } + }, }); } : undefined, - [currentIndexPattern.id, dataViews, editPermission, indexPatternFieldEditor, refreshFieldList] + [ + currentIndexPattern.id, + dataViews, + editPermission, + indexPatternFieldEditor, + indexPatternService, + refreshFieldList, + ] ); const fieldProps = useMemo( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 74f006a0c3174..7c4ca5f9db255 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -41,6 +41,7 @@ import { injectReferences, loadInitialState, onRefreshIndexPattern, + renameIndexPattern, triggerActionOnIndexPatternChange, } from './loader'; import { toExpression } from './to_expression'; @@ -464,6 +465,9 @@ export function getIndexPatternDatasource({ } return changeIndexPattern({ indexPatternId, state, storage, indexPatterns }); }, + onIndexPatternRename: (state, oldIndexPatternId, newIndexPatternId) => { + return renameIndexPattern({ state, oldIndexPatternId, newIndexPatternId }); + }, getRenderEventCounters(state: IndexPatternPrivateState): string[] { const additionalEvents = { time_shift: false, @@ -550,15 +554,18 @@ export function getIndexPatternDatasource({ } return null; }, - getSourceId: () => layer.indexPatternId, - getFilters: (activeData: FramePublicAPI['activeData'], timeRange?: TimeRange) => - getFiltersInLayer( + getSourceId: () => { + return layer.indexPatternId; + }, + getFilters: (activeData: FramePublicAPI['activeData'], timeRange?: TimeRange) => { + return getFiltersInLayer( layer, visibleColumnIds, activeData?.[layerId], indexPatterns[layer.indexPatternId], timeRange - ), + ); + }, getVisualDefaults: () => getVisualDefaultsForLayer(layer), getMaxPossibleNumValues: (columnId) => { if (layer && layer.columns[columnId]) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index f7a6912390027..b3bbc57dbb8a4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -49,16 +49,18 @@ function getLayerReferenceName(layerId: string) { export function extractReferences({ layers }: IndexPatternPrivateState) { const savedObjectReferences: SavedObjectReference[] = []; - const persistableLayers: Record> = {}; + const persistableState: IndexPatternPersistedState = { + layers: {}, + }; Object.entries(layers).forEach(([layerId, { indexPatternId, ...persistableLayer }]) => { + persistableState.layers[layerId] = persistableLayer; savedObjectReferences.push({ type: 'index-pattern', id: indexPatternId, name: getLayerReferenceName(layerId), }); - persistableLayers[layerId] = persistableLayer; }); - return { savedObjectReferences, state: { layers: persistableLayers } }; + return { savedObjectReferences, state: persistableState }; } export function injectReferences( @@ -200,6 +202,27 @@ export function changeIndexPattern({ }; } +export function renameIndexPattern({ + oldIndexPatternId, + newIndexPatternId, + state, +}: { + oldIndexPatternId: string; + newIndexPatternId: string; + state: IndexPatternPrivateState; +}) { + return { + ...state, + layers: mapValues(state.layers, (layer) => + layer.indexPatternId === oldIndexPatternId + ? { ...layer, indexPatternId: newIndexPatternId } + : layer + ), + currentIndexPatternId: + state.currentIndexPatternId === oldIndexPatternId ? newIndexPatternId : oldIndexPatternId, + }; +} + export function triggerActionOnIndexPatternChange({ state, layerId, @@ -211,7 +234,8 @@ export function triggerActionOnIndexPatternChange({ state: IndexPatternPrivateState; uiActions: UiActionsStart; }) { - const fromDataView = state.layers[layerId].indexPatternId; + const fromDataView = state.layers[layerId]?.indexPatternId; + if (!fromDataView) return; const toDataView = indexPatternId; const trigger = uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts index d76e6723c1be9..2a1f93f5b31ca 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts @@ -101,6 +101,7 @@ export const createMockedIndexPattern = (): IndexPattern => { hasRestrictions: false, fields, getFieldByName: getFieldByNameFactory(fields), + spec: undefined, }; }; @@ -140,6 +141,7 @@ export const createMockedRestrictedIndexPattern = () => { fieldFormatMap: { bytes: { id: 'bytes', params: { pattern: '0.0' } } }, fields, getFieldByName: getFieldByNameFactory(fields), + spec: undefined, typeMeta: { params: { rollup_index: 'my-fake-index-pattern', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 46a9ffaafb566..5ff9b183d2579 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import type { DragDropIdentifier } from '../drag_drop/providers'; import type { IncompleteColumn, GenericIndexPatternColumn } from './operations'; import type { DragDropOperation } from '../types'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx index d67023a1a24be..0f485d71a7f35 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx @@ -465,6 +465,14 @@ export function getFiltersInLayer( indexPattern: IndexPattern, timeRange: TimeRange | undefined ) { + if (indexPattern.spec) { + return { + error: i18n.translate('xpack.lens.indexPattern.adHocDataViewError', { + defaultMessage: + '"Explore data in Discover" does not support unsaved data views. Save the data view to switch to Discover.', + }), + }; + } const filtersGroupedByState = collectFiltersFromMetrics(layer, columnIds); const [enabledFiltersFromMetricsByLanguage, disabledFitleredFromMetricsByLanguage] = ( ['enabled', 'disabled'] as const diff --git a/x-pack/plugins/lens/public/indexpattern_service/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_service/loader.test.ts index 912028fba81a2..dc9edff3c31b7 100644 --- a/x-pack/plugins/lens/public/indexpattern_service/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_service/loader.test.ts @@ -64,6 +64,7 @@ describe('loader', () => { id: 'foo', title: 'Foo index', metaFields: [], + isPersisted: () => true, typeMeta: { aggs: { date_histogram: { @@ -100,7 +101,8 @@ describe('loader', () => { id: 'foo', title: 'Foo index', })), - } as unknown as Pick, + create: jest.fn(), + } as unknown as Pick, }); expect(cache.foo.getFieldByName('bytes')!.aggregationRestrictions).toEqual({ @@ -120,6 +122,7 @@ describe('loader', () => { id: 'foo', title: 'Foo index', metaFields: ['timestamp'], + isPersisted: () => true, typeMeta: { aggs: { date_histogram: { @@ -156,7 +159,8 @@ describe('loader', () => { id: 'foo', title: 'Foo index', })), - } as unknown as Pick, + create: jest.fn(), + } as unknown as Pick, }); expect(cache.foo.getFieldByName('timestamp')!.meta).toEqual(true); @@ -198,12 +202,13 @@ describe('loader', () => { timeFieldName: 'timestamp', hasRestrictions: false, fields: [], + isPersisted: () => true, }; } return Promise.reject(); }), getIdsWithTitle: jest.fn(), - } as unknown as Pick; + } as unknown as Pick; const cache = await loadIndexPatterns({ cache: {}, patterns: ['1', '2'], @@ -234,7 +239,7 @@ describe('loader', () => { throw err; }), getIdsWithTitle: jest.fn(), - } as unknown as Pick, + } as unknown as Pick, onError, }); diff --git a/x-pack/plugins/lens/public/indexpattern_service/loader.ts b/x-pack/plugins/lens/public/indexpattern_service/loader.ts index 45dd7eb9db8f3..c0b6a9fdc8f76 100644 --- a/x-pack/plugins/lens/public/indexpattern_service/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_service/loader.ts @@ -6,7 +6,7 @@ */ import { isNestedField } from '@kbn/data-views-plugin/common'; -import type { DataViewsContract, DataView } from '@kbn/data-views-plugin/public'; +import type { DataViewsContract, DataView, DataViewSpec } from '@kbn/data-views-plugin/public'; import { keyBy } from 'lodash'; import { HttpSetup } from '@kbn/core/public'; import { IndexPattern, IndexPatternField, IndexPatternMap, IndexPatternRef } from '../types'; @@ -15,7 +15,7 @@ import { BASE_API_URL, DateRange, ExistingFields } from '../../common'; import { DataViewsState } from '../state_management'; type ErrorHandler = (err: Error) => void; -type MinimalDataViewsContract = Pick; +type MinimalDataViewsContract = Pick; /** * All these functions will be used by the Embeddable instance too, @@ -92,17 +92,27 @@ export function convertDataViewIntoLensIndexPattern( fields: newFields, getFieldByName: getFieldByNameFactory(newFields), hasRestrictions: !!typeMeta?.aggs, + spec: dataView.isPersisted() ? undefined : dataView.toSpec(false), }; } export async function loadIndexPatternRefs( - dataViews: MinimalDataViewsContract + dataViews: MinimalDataViewsContract, + adHocDataViews?: Record ): Promise { const indexPatterns = await dataViews.getIdsWithTitle(); - return indexPatterns.sort((a, b) => { - return a.title.localeCompare(b.title); - }); + return indexPatterns + .concat( + Object.values(adHocDataViews || {}).map((dataViewSpec) => ({ + id: dataViewSpec.id!, + name: dataViewSpec.name, + title: dataViewSpec.title!, + })) + ) + .sort((a, b) => { + return a.title.localeCompare(b.title); + }); } /** @@ -122,17 +132,20 @@ export async function loadIndexPatterns({ patterns, notUsedPatterns, cache, + adHocDataViews, onIndexPatternRefresh, }: { dataViews: MinimalDataViewsContract; patterns: string[]; notUsedPatterns?: string[]; cache: Record; + adHocDataViews?: Record; onIndexPatternRefresh?: () => void; }) { - const missingIds = patterns.filter((id) => !cache[id]); + const missingIds = patterns.filter((id) => !cache[id] && !adHocDataViews?.[id]); + const hasAdHocDataViews = Object.values(adHocDataViews || {}).length > 0; - if (missingIds.length === 0) { + if (missingIds.length === 0 && !hasAdHocDataViews) { return cache; } @@ -147,7 +160,7 @@ export async function loadIndexPatterns({ .map((response) => response.value); // if all of the used index patterns failed to load, try loading one of not used ones till one succeeds - if (!indexPatterns.length && notUsedPatterns) { + if (!indexPatterns.length && !hasAdHocDataViews && notUsedPatterns) { for (const notUsedPattern of notUsedPatterns) { const resp = await dataViews.get(notUsedPattern).catch((e) => { // do nothing @@ -157,6 +170,11 @@ export async function loadIndexPatterns({ } } } + indexPatterns.push( + ...(await Promise.all( + Object.values(adHocDataViews || {}).map((spec) => dataViews.create(spec)) + )) + ); const indexPatternsObject = indexPatterns.reduce( (acc, indexPattern) => ({ @@ -228,6 +246,10 @@ async function refreshExistingFields({ body.timeFieldName = pattern.timeFieldName; } + if (pattern.spec) { + body.spec = pattern.spec; + } + return fetchJson(`${BASE_API_URL}/existing_fields/${pattern.id}`, { body: JSON.stringify(body), }) as Promise; diff --git a/x-pack/plugins/lens/public/indexpattern_service/mocks.ts b/x-pack/plugins/lens/public/indexpattern_service/mocks.ts index 6eb7156a91cff..048333786fdd6 100644 --- a/x-pack/plugins/lens/public/indexpattern_service/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_service/mocks.ts @@ -42,6 +42,7 @@ const indexPattern1 = { title: 'my-fake-index-pattern', timeFieldName: 'timestamp', hasRestrictions: false, + isPersisted: () => true, fields: [ { name: 'timestamp', @@ -127,6 +128,7 @@ const indexPattern2 = { title: 'my-fake-restricted-pattern', timeFieldName: 'timestamp', hasRestrictions: true, + isPersisted: () => true, fieldFormatMap: { bytes: { id: 'bytes', params: { pattern: '0.0' } } }, fields: [ { @@ -198,7 +200,11 @@ export const sampleIndexPatterns = { export function mockDataViewsService() { return { get: jest.fn(async (id: '1' | '2') => { - const result = { ...sampleIndexPatternsFromService[id], metaFields: [] }; + const result = { + ...sampleIndexPatternsFromService[id], + metaFields: [], + isPersisted: () => true, + }; if (!result.fields) { result.fields = []; } @@ -216,5 +222,6 @@ export function mockDataViewsService() { }, ]; }), - } as unknown as Pick; + create: jest.fn(), + } as unknown as Pick; } diff --git a/x-pack/plugins/lens/public/indexpattern_service/service.ts b/x-pack/plugins/lens/public/indexpattern_service/service.ts index bbce7f83cf25a..91e070d52d48a 100644 --- a/x-pack/plugins/lens/public/indexpattern_service/service.ts +++ b/x-pack/plugins/lens/public/indexpattern_service/service.ts @@ -5,9 +5,14 @@ * 2.0. */ -import type { DataViewsContract } from '@kbn/data-views-plugin/public'; +import type { DataViewsContract, DataView } from '@kbn/data-views-plugin/public'; import type { CoreStart, IUiSettingsClient } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; +import { ActionExecutionContext, UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import { + UPDATE_FILTER_REFERENCES_ACTION, + UPDATE_FILTER_REFERENCES_TRIGGER, +} from '@kbn/unified-search-plugin/public'; import type { DateRange } from '../../common'; import type { IndexPattern, IndexPatternMap, IndexPatternRef } from '../types'; import { @@ -17,15 +22,22 @@ import { syncExistingFields, } from './loader'; import type { DataViewsState } from '../state_management'; +import { generateId } from '../id_generator'; export interface IndexPatternServiceProps { core: Pick; dataViews: DataViewsContract; uiSettings: IUiSettingsClient; + uiActions: UiActionsStart; updateIndexPatterns: ( newState: Partial, options?: { applyImmediately: boolean } ) => void; + replaceIndexPattern: ( + newIndexPattern: IndexPattern, + oldId: string, + options?: { applyImmediately: boolean } + ) => void; } /** @@ -69,6 +81,8 @@ export interface IndexPatternServiceAPI { indexPatternList: IndexPattern[]; isFirstExistenceFetch: boolean; }) => Promise; + + replaceDataViewId: (newDataView: DataView) => Promise; /** * Retrieves the default indexPattern from the uiSettings */ @@ -88,6 +102,8 @@ export function createIndexPatternService({ dataViews, uiSettings, updateIndexPatterns, + replaceIndexPattern, + uiActions, }: IndexPatternServiceProps): IndexPatternServiceAPI { const onChangeError = (err: Error) => core.notifications.toasts.addError(err, { @@ -103,6 +119,27 @@ export function createIndexPatternService({ ...args, }); }, + replaceDataViewId: async (dataView: DataView) => { + const newDataView = await dataViews.create({ ...dataView.toSpec(), id: generateId() }); + dataViews.clearInstanceCache(dataView.id); + const loadedPatterns = await loadIndexPatterns({ + dataViews, + patterns: [newDataView.id!], + cache: {}, + }); + replaceIndexPattern(loadedPatterns[newDataView.id!], dataView.id!, { + applyImmediately: true, + }); + const trigger = uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER); + const action = uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION); + + action?.execute({ + trigger, + fromDataView: dataView.id, + toDataView: newDataView.id, + usedDataViews: [], + } as ActionExecutionContext); + }, ensureIndexPattern: (args) => ensureIndexPattern({ onError: onChangeError, dataViews, ...args }), refreshExistingFields: (args) => 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 157c910440fb6..8260c71b04981 100644 --- a/x-pack/plugins/lens/public/mocks/data_plugin_mock.ts +++ b/x-pack/plugins/lens/public/mocks/data_plugin_mock.ts @@ -102,7 +102,7 @@ export function mockDataPlugin( extract: (filtersIn: Filter[]) => { const state = filtersIn.map((filter) => ({ ...filter, - meta: { ...filter.meta, index: 'extracted!' }, + meta: { ...filter.meta }, })); return { state, references: [] }; }, @@ -127,6 +127,13 @@ export function mockDataPlugin( indexPatterns: { get: jest.fn().mockImplementation((id) => Promise.resolve({ id, isTimeBased: () => true })), }, + dataViews: { + get: jest + .fn() + .mockImplementation((id) => + Promise.resolve({ id, isTimeBased: () => true, isPersisted: () => true }) + ), + }, search: createMockSearchService(), nowProvider: { get: jest.fn(), diff --git a/x-pack/plugins/lens/public/mocks/data_views_service_mock.ts b/x-pack/plugins/lens/public/mocks/data_views_service_mock.ts index 56ff7015bdb55..e2a07c717ee07 100644 --- a/x-pack/plugins/lens/public/mocks/data_views_service_mock.ts +++ b/x-pack/plugins/lens/public/mocks/data_views_service_mock.ts @@ -8,6 +8,7 @@ import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; import { coreMock } from '@kbn/core/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; import { createIndexPatternService, IndexPatternServiceProps, @@ -18,7 +19,16 @@ export function createIndexPatternServiceMock({ core = coreMock.createStart(), uiSettings = uiSettingsServiceMock.createStartContract(), dataViews = dataViewPluginMocks.createStartContract(), + uiActions = uiActionsPluginMock.createStartContract(), updateIndexPatterns = jest.fn(), + replaceIndexPattern = jest.fn(), }: Partial = {}): IndexPatternServiceAPI { - return createIndexPatternService({ core, uiSettings, updateIndexPatterns, dataViews }); + return createIndexPatternService({ + core, + uiSettings, + updateIndexPatterns, + replaceIndexPattern, + dataViews, + uiActions, + }); } diff --git a/x-pack/plugins/lens/public/mocks/services_mock.tsx b/x-pack/plugins/lens/public/mocks/services_mock.tsx index 8c323e7e071fa..3636816f72a43 100644 --- a/x-pack/plugins/lens/public/mocks/services_mock.tsx +++ b/x-pack/plugins/lens/public/mocks/services_mock.tsx @@ -27,6 +27,7 @@ import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks' import type { EmbeddableStateTransfer } from '@kbn/embeddable-plugin/public'; import { presentationUtilPluginMock } from '@kbn/presentation-util-plugin/public/mocks'; +import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; import type { LensAttributeService } from '../lens_attribute_service'; import type { LensByValueInput, @@ -87,7 +88,7 @@ export function makeDefaultServices( const dataViewsMock = dataViewPluginMocks.createStartContract(); dataViewsMock.get.mockImplementation( jest.fn((id) => - Promise.resolve({ id, isTimeBased: () => true, fields: [] }) + Promise.resolve({ id, isTimeBased: () => true, fields: [], isPersisted: () => true }) ) as unknown as DataViewsPublicPluginStart['get'] ); dataViewsMock.getIdsWithTitle.mockImplementation(jest.fn(async () => [])); @@ -158,6 +159,7 @@ export function makeDefaultServices( remove: jest.fn(), clear: jest.fn(), }, + uiActions: uiActionsPluginMock.createStartContract(), spaces: spacesPluginMock.createStartContract(), charts: chartPluginMock.createSetupContract(), dataViewFieldEditor: indexPatternFieldEditorPluginMock.createStartContract(), 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 ae2c55f66cc51..a059be6db3bfa 100644 --- a/x-pack/plugins/lens/public/persistence/saved_object_store.ts +++ b/x-pack/plugins/lens/public/persistence/saved_object_store.ts @@ -12,6 +12,7 @@ import { SavedObjectReference, ResolvedSimpleSavedObject, } from '@kbn/core/public'; +import { DataViewSpec } from '@kbn/data-views-plugin/public'; import { DOC_TYPE } from '../../common'; import { LensSavedObjectAttributes } from '../async_services'; @@ -30,6 +31,8 @@ export interface Document { state?: unknown; }; filters: Filter[]; + adHocDataViews?: Record; + internalReferences?: SavedObjectReference[]; }; references: SavedObjectReference[]; } 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 5ee4c82d7ce5f..0a000f75ee006 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 @@ -119,6 +119,7 @@ export function loadInitial( datasourceMap, datasourceStates: lens.datasourceStates, initialContext, + adHocDataViews: lens.persistedDoc?.state.adHocDataViews, ...loaderSharedArgs, }, { @@ -190,10 +191,11 @@ export function loadInitial( { datasourceMap, datasourceStates: docDatasourceStates, - references: doc.references, + references: [...doc.references, ...(doc.state.internalReferences || [])], initialContext, dataViews: lensServices.dataViews, storage: lensServices.storage, + adHocDataViews: doc.state.adHocDataViews, defaultIndexPatternId: lensServices.uiSettings.get('defaultIndex'), }, { isFullEditor: true } diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index b880b9478c16a..aaf34f62305c5 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -12,7 +12,7 @@ import { Query } from '@kbn/es-query'; import { History } from 'history'; import { LensEmbeddableInput } from '..'; import { TableInspectorAdapter } from '../editor_frame_service/types'; -import type { VisualizeEditorContext, Suggestion } from '../types'; +import type { VisualizeEditorContext, Suggestion, IndexPattern } from '../types'; import { getInitialDatasourceId, getResolvedDateRange, getRemoveOperation } from '../utils'; import type { DataViewsState, LensAppState, LensStoreDeps, VisualizationState } from './types'; import type { Datasource, Visualization } from '../types'; @@ -172,6 +172,9 @@ export const setLayerDefaultDimension = createAction<{ export const updateIndexPatterns = createAction>( 'lens/updateIndexPatterns' ); +export const replaceIndexpattern = createAction<{ newIndexPattern: IndexPattern; oldId: string }>( + 'lens/replaceIndexPattern' +); export const changeIndexPattern = createAction<{ visualizationIds?: string[]; datasourceIds?: string[]; @@ -206,6 +209,7 @@ export const lensActions = { addLayer, setLayerDefaultDimension, updateIndexPatterns, + replaceIndexpattern, changeIndexPattern, }; @@ -316,7 +320,25 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { } ) => { const { visualizationIds, datasourceIds, layerId, indexPatternId, dataViews } = payload; - const newState: Partial = { dataViews: { ...state.dataViews, ...dataViews } }; + const newIndexPatternRefs = [...state.dataViews.indexPatternRefs]; + const availableRefs = new Set(newIndexPatternRefs.map((ref) => ref.id)); + // check for missing refs + Object.values(dataViews.indexPatterns || {}).forEach((indexPattern) => { + if (!availableRefs.has(indexPattern.id)) { + newIndexPatternRefs.push({ + id: indexPattern.id!, + name: indexPattern.name, + title: indexPattern.title, + }); + } + }); + const newState: Partial = { + dataViews: { + ...state.dataViews, + indexPatterns: dataViews.indexPatterns, + indexPatternRefs: newIndexPatternRefs, + }, + }; if (visualizationIds?.length) { for (const visualizationId of visualizationIds) { const activeVisualization = @@ -402,6 +424,38 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { dataViews: { ...state.dataViews, ...payload }, }; }, + [replaceIndexpattern.type]: ( + state, + { payload }: { payload: { newIndexPattern: IndexPattern; oldId: string } } + ) => { + state.dataViews.indexPatterns[payload.newIndexPattern.id] = payload.newIndexPattern; + delete state.dataViews.indexPatterns[payload.oldId]; + state.dataViews.indexPatternRefs = state.dataViews.indexPatternRefs.filter( + (r) => r.id !== payload.oldId + ); + state.dataViews.indexPatternRefs.push({ + id: payload.newIndexPattern.id, + title: payload.newIndexPattern.title, + name: payload.newIndexPattern.name, + }); + const visualization = visualizationMap[state.visualization.activeId!]; + state.visualization.state = + visualization.onIndexPatternRename?.( + state.visualization.state, + payload.oldId, + payload.newIndexPattern.id + ) ?? state.visualization.state; + + Object.entries(state.datasourceStates).forEach(([datasourceId, datasourceState]) => { + const datasource = datasourceMap[datasourceId]; + state.datasourceStates[datasourceId].state = + datasource?.onIndexPatternRename?.( + datasourceState.state, + payload.oldId, + payload.newIndexPattern.id! + ) ?? datasourceState.state; + }); + }, [updateDatasourceState.type]: ( state, { diff --git a/x-pack/plugins/lens/public/state_management/selectors.ts b/x-pack/plugins/lens/public/state_management/selectors.ts index f1f53197978fa..447eccd1705fd 100644 --- a/x-pack/plugins/lens/public/state_management/selectors.ts +++ b/x-pack/plugins/lens/public/state_management/selectors.ts @@ -8,6 +8,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { FilterManager } from '@kbn/data-plugin/public'; import { SavedObjectReference } from '@kbn/core/public'; +import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common'; import { LensState } from './types'; import { Datasource, DatasourceMap, VisualizationMap } from '../types'; import { getDatasourceLayers } from '../editor_frame_service/editor_frame'; @@ -17,6 +18,12 @@ export const selectQuery = (state: LensState) => state.lens.query; export const selectSearchSessionId = (state: LensState) => state.lens.searchSessionId; export const selectFilters = (state: LensState) => state.lens.filters; export const selectResolvedDateRange = (state: LensState) => state.lens.resolvedDateRange; +export const selectAdHocDataViews = (state: LensState) => + Object.fromEntries( + Object.values(state.lens.dataViews.indexPatterns) + .filter((indexPattern) => indexPattern.spec) + .map((indexPattern) => [indexPattern.id, indexPattern.spec!]) + ); export const selectVisualization = (state: LensState) => state.lens.visualization; export const selectStagedPreview = (state: LensState) => state.lens.stagedPreview; export const selectStagedActiveData = (state: LensState) => @@ -69,6 +76,7 @@ export const selectSavedObjectFormat = createSelector( selectQuery, selectFilters, selectActiveDatasourceId, + selectAdHocDataViews, selectInjectedDependencies as SelectInjectedDependenciesFunction<{ datasourceMap: DatasourceMap; visualizationMap: VisualizationMap; @@ -82,6 +90,7 @@ export const selectSavedObjectFormat = createSelector( query, filters, activeDatasourceId, + adHocDataViews, { datasourceMap, visualizationMap, extractFilterReferences } ) => { const activeVisualization = @@ -105,16 +114,40 @@ export const selectSavedObjectFormat = createSelector( const persistibleDatasourceStates: Record = {}; const references: SavedObjectReference[] = []; + const internalReferences: SavedObjectReference[] = []; Object.entries(activeDatasources).forEach(([id, datasource]) => { const { state: persistableState, savedObjectReferences } = datasource.getPersistableState( datasourceStates[id].state ); persistibleDatasourceStates[id] = persistableState; - references.push(...savedObjectReferences); + savedObjectReferences.forEach((r) => { + if (r.type === 'index-pattern' && adHocDataViews[r.id]) { + internalReferences.push(r); + } else { + references.push(r); + } + }); }); + const persistableAdHocDataViews = Object.fromEntries( + Object.entries(adHocDataViews).map(([id, dataView]) => { + const { references: dataViewReferences, state } = + DataViewPersistableStateService.extract(dataView); + references.push(...dataViewReferences); + return [id, state]; + }) + ); + + const adHocFilters = filters + .filter((f) => !references.some((r) => r.type === 'index-pattern' && r.id === f.meta.index)) + .map((f) => ({ ...f, meta: { ...f.meta, value: undefined } })); + + const referencedFilters = filters.filter((f) => + references.some((r) => r.type === 'index-pattern' && r.id === f.meta.index) + ); + const { state: persistableFilters, references: filterReferences } = - extractFilterReferences(filters); + extractFilterReferences(referencedFilters); references.push(...filterReferences); @@ -128,8 +161,10 @@ export const selectSavedObjectFormat = createSelector( state: { visualization: visualization.state, query, - filters: persistableFilters, + filters: [...persistableFilters, ...adHocFilters], datasourceStates: persistibleDatasourceStates, + internalReferences, + adHocDataViews: persistableAdHocDataViews, }, }; } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 97df24648f4d4..66c0e3b335adb 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -26,7 +26,7 @@ import type { } from '@kbn/ui-actions-plugin/public'; import type { ClickTriggerEvent, BrushTriggerEvent } from '@kbn/charts-plugin/public'; import type { IndexPatternAggRestrictions } from '@kbn/data-plugin/public'; -import type { FieldSpec } from '@kbn/data-views-plugin/common'; +import type { FieldSpec, DataViewSpec } from '@kbn/data-views-plugin/common'; import type { FieldFormatParams } from '@kbn/field-formats-plugin/common'; import type { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop'; import type { DateRange, LayerType, SortingHint } from '../common'; @@ -69,6 +69,7 @@ export interface IndexPattern { } >; hasRestrictions: boolean; + spec?: DataViewSpec; } export type IndexPatternField = FieldSpec & { @@ -340,6 +341,12 @@ export interface Datasource { indexPatternId: string, layerId?: string ) => T; + onIndexPatternRename?: (state: T, oldIndexPatternId: string, newIndexPatternId: string) => T; + triggerOnIndexPatternChange?: ( + state: T, + oldIndexPatternId: string, + newIndexPatternId: string + ) => void; onRefreshIndexPattern: () => void; @@ -906,7 +913,7 @@ export interface Visualization { /** Optional, if the visualization supports multiple layers */ removeLayer?: (state: T, layerId: string) => T; /** Track added layers in internal state */ - appendLayer?: (state: T, layerId: string, type: LayerType) => T; + appendLayer?: (state: T, layerId: string, type: LayerType, indexPatternId?: string) => T; /** Retrieve a list of supported layer types with initialization data */ getSupportedLayers: ( @@ -1083,6 +1090,7 @@ export interface Visualization { * This method makes it aware of the change and produces a new updated state */ onIndexPatternChange?: (state: T, indexPatternId: string, layerId?: string) => T; + onIndexPatternRename?: (state: T, oldIndexPatternId: string, newIndexPatternId: string) => T; /** * Gets custom display options for showing the visualization. */ diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index e5995762b5505..c8501d6efc512 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -93,14 +93,11 @@ export async function refreshIndexPatternsList({ .map((datasource) => datasource?.onRefreshIndexPattern) .filter(Boolean); - const [newlyMappedIndexPattern, indexPatternRefs] = await Promise.all([ - indexPatternService.loadIndexPatterns({ - cache: {}, - patterns: [indexPatternId], - onIndexPatternRefresh: () => onRefreshCallbacks.forEach((fn) => fn()), - }), - indexPatternService.loadIndexPatternRefs({ isFullEditor: true }), - ]); + const newlyMappedIndexPattern = await indexPatternService.loadIndexPatterns({ + cache: {}, + patterns: [indexPatternId], + onIndexPatternRefresh: () => onRefreshCallbacks.forEach((fn) => fn()), + }); const indexPattern = newlyMappedIndexPattern[indexPatternId]; // But what about existingFields here? // When the indexPatterns cache object gets updated, the data panel will @@ -110,7 +107,6 @@ export async function refreshIndexPatternsList({ ...indexPatternsCache, [indexPatternId]: indexPattern, }, - indexPatternRefs, }); } diff --git a/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx b/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx index 20bd48fb9dd95..f1b1ffd1f00b4 100644 --- a/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx @@ -235,6 +235,7 @@ export const getLegacyMetricVisualization = ({ groups: [ { groupId: 'metric', + dataTestSubj: 'lnsLegacyMetric_metricDimensionPanel', paramEditorCustomProps: { headingLabel: i18n.translate('xpack.lens.metric.headingLabel', { defaultMessage: 'Value', 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 index 3bd45dfe873f3..efde77c656bc5 100644 --- 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 @@ -10,14 +10,21 @@ import { makeLensEmbeddableFactory } from './make_lens_embeddable_factory'; import { getAllMigrations } from '../migrations/saved_object_migrations'; import { Filter } from '@kbn/es-query'; import { GetMigrationFunctionObjectFn } from '@kbn/kibana-utils-plugin/common'; +import { DataViewSpec } from '@kbn/data-views-plugin/common'; 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 savedObjectMigrationVersions = Object.keys(getAllMigrations({}, {}, {})).filter( + (version) => { + return semverGte(version, '7.13.1'); + } + ); const embeddableMigrationVersions = ( - makeLensEmbeddableFactory(() => ({}), {})()?.migrations as GetMigrationFunctionObjectFn + makeLensEmbeddableFactory( + () => ({}), + () => ({}), + {} + )()?.migrations as GetMigrationFunctionObjectFn )(); if (embeddableMigrationVersions) { expect(savedObjectMigrationVersions.sort()).toEqual( @@ -56,6 +63,7 @@ describe('embeddable migrations', () => { })); }, }), + () => ({}), {} )()?.migrations as GetMigrationFunctionObjectFn )(); @@ -80,6 +88,60 @@ describe('embeddable migrations', () => { }); }); + test('should properly apply a data view migration within a lens visualization', () => { + const migrationVersion = 'some-version'; + + const lensVisualizationDoc = { + attributes: { + state: { + adHocDataViews: { + abc: { + id: 'abc', + }, + def: { + id: 'def', + name: 'A name', + }, + }, + }, + }, + }; + + const migrations = ( + makeLensEmbeddableFactory( + () => ({}), + () => ({ + [migrationVersion]: (dataView: DataViewSpec) => { + return { + ...dataView, + name: dataView.id, + }; + }, + }), + {} + )()?.migrations as GetMigrationFunctionObjectFn + )(); + + const migratedLensDoc = migrations[migrationVersion](lensVisualizationDoc); + + expect(migratedLensDoc).toEqual({ + attributes: { + state: { + adHocDataViews: { + abc: { + id: 'abc', + name: 'abc', + }, + def: { + id: 'def', + name: 'def', + }, + }, + }, + }, + }); + }); + test('should properly apply a custom visualization migration', () => { const migrationVersion = 'some-version'; @@ -97,11 +159,15 @@ describe('embeddable migrations', () => { })); const embeddableMigrationVersions = ( - makeLensEmbeddableFactory(() => ({}), { - abc: () => ({ - [migrationVersion]: migrationFn, - }), - })()?.migrations as GetMigrationFunctionObjectFn + makeLensEmbeddableFactory( + () => ({}), + () => ({}), + { + abc: () => ({ + [migrationVersion]: migrationFn, + }), + } + )()?.migrations as GetMigrationFunctionObjectFn )(); const migratedLensDoc = embeddableMigrationVersions?.[migrationVersion](lensVisualizationDoc); 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 index 98b5f5d8d186d..8e3f6f48cf747 100644 --- a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts @@ -27,6 +27,7 @@ import { commonUpdateVisLayerType, getLensCustomVisualizationMigrations, getLensFilterMigrations, + getLensDataViewMigrations, commonMigrateMetricIds, } from '../migrations/common_migrations'; import { @@ -47,6 +48,7 @@ import { extract, inject } from '../../common/embeddable_factory'; export const makeLensEmbeddableFactory = ( getFilterMigrations: () => MigrateFunctionsObject, + getDataViewMigrations: () => MigrateFunctionsObject, customVisualizationMigrations: CustomVisualizationMigrations ) => (): EmbeddableRegistryDefinition => { @@ -54,89 +56,94 @@ export const makeLensEmbeddableFactory = id: DOC_TYPE, migrations: () => mergeMigrationFunctionMaps( - mergeMigrationFunctionMaps(getLensFilterMigrations(getFilterMigrations()), { - // 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 = commonRenameRecordsField( - commonRenameFilterReferences(lensState.attributes) - ); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - '8.2.0': (state) => { - const lensState = state as unknown as { - attributes: LensDocShape810; - }; - let migratedLensState = commonSetLastValueShowArrayValues(lensState.attributes); - migratedLensState = commonEnhanceTableRowHeight( - migratedLensState as LensDocShape810 - ); - migratedLensState = commonSetIncludeEmptyRowsDateHistogram(migratedLensState); + mergeMigrationFunctionMaps( + mergeMigrationFunctionMaps(getLensFilterMigrations(getFilterMigrations()), { + // 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 = commonRenameRecordsField( + commonRenameFilterReferences(lensState.attributes) + ); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + '8.2.0': (state) => { + const lensState = state as unknown as { + attributes: LensDocShape810; + }; + let migratedLensState = commonSetLastValueShowArrayValues(lensState.attributes); + migratedLensState = commonEnhanceTableRowHeight( + migratedLensState as LensDocShape810 + ); + migratedLensState = commonSetIncludeEmptyRowsDateHistogram(migratedLensState); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - '8.3.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape810 }; - let migratedLensState = commonLockOldMetricVisSettings(lensState.attributes); - migratedLensState = commonPreserveOldLegendSizeDefault(migratedLensState); - migratedLensState = commonFixValueLabelsInXY( - migratedLensState as LensDocShape810 - ); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - '8.5.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape840 }; - const migratedLensState = commonMigrateMetricIds(lensState.attributes); - return { - ...lensState, - attributes: migratedLensState, - } as unknown as SerializableRecord; - }, - }), - getLensCustomVisualizationMigrations(customVisualizationMigrations) + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + '8.3.0': (state) => { + const lensState = state as unknown as { attributes: LensDocShape810 }; + let migratedLensState = commonLockOldMetricVisSettings(lensState.attributes); + migratedLensState = commonPreserveOldLegendSizeDefault(migratedLensState); + migratedLensState = commonFixValueLabelsInXY( + migratedLensState as LensDocShape810 + ); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + '8.5.0': (state) => { + const lensState = state as unknown as { attributes: LensDocShape840 }; + const migratedLensState = commonMigrateMetricIds(lensState.attributes); + return { + ...lensState, + attributes: migratedLensState, + } as unknown as SerializableRecord; + }, + }), + getLensCustomVisualizationMigrations(customVisualizationMigrations) + ), + getLensDataViewMigrations(getDataViewMigrations()) ), 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 index 55c7bc641a04e..1a751022f966f 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.test.ts @@ -5,8 +5,9 @@ * 2.0. */ +import { DataViewSpec } from '@kbn/data-views-plugin/common'; import { Filter } from '@kbn/es-query'; -import { getLensFilterMigrations } from './common_migrations'; +import { getLensDataViewMigrations, getLensFilterMigrations } from './common_migrations'; describe('Lens migrations', () => { describe('applying filter migrations', () => { @@ -41,4 +42,68 @@ describe('Lens migrations', () => { ]); }); }); + + describe('applying data view migrations', () => { + it('creates a data view migrations map that works on a lens visualization', () => { + const dataViewMigrations = { + '1.1': (dataView: DataViewSpec) => ({ ...dataView, name: '1.1' }), + '2.2': (dataView: DataViewSpec) => ({ ...dataView, name: '2.2' }), + '3.3': (dataView: DataViewSpec) => ({ ...dataView, name: '3.3' }), + }; + + const lensVisualizationSavedObject = { + attributes: { + state: { + adHocDataViews: { + abc: { + id: 'abc', + }, + def: { + id: 'def', + }, + }, + }, + }, + }; + + const migrationMap = getLensDataViewMigrations(dataViewMigrations); + + expect( + migrationMap['1.1'](lensVisualizationSavedObject).attributes.state.adHocDataViews + ).toEqual({ + abc: { + id: 'abc', + name: '1.1', + }, + def: { + id: 'def', + name: '1.1', + }, + }); + expect( + migrationMap['2.2'](lensVisualizationSavedObject).attributes.state.adHocDataViews + ).toEqual({ + abc: { + id: 'abc', + name: '2.2', + }, + def: { + id: 'def', + name: '2.2', + }, + }); + expect( + migrationMap['3.3'](lensVisualizationSavedObject).attributes.state.adHocDataViews + ).toEqual({ + abc: { + id: 'abc', + name: '3.3', + }, + def: { + id: 'def', + name: '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 2681a7f2abe2e..b56ee44c7a791 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -347,6 +347,27 @@ export const getLensFilterMigrations = ( }, })); +export const getLensDataViewMigrations = ( + dataViewMigrations: MigrateFunctionsObject +): MigrateFunctionsObject => + mapValues(dataViewMigrations, (migrate) => (lensDoc: { attributes: LensDocShape }) => ({ + ...lensDoc, + attributes: { + ...lensDoc.attributes, + state: { + ...lensDoc.attributes.state, + adHocDataViews: !lensDoc.attributes.state.adHocDataViews + ? undefined + : Object.fromEntries( + Object.entries(lensDoc.attributes.state.adHocDataViews).map(([id, spec]) => [ + id, + migrate(spec), + ]) + ), + }, + }, + })); + export const fixLensTopValuesCustomFormatting = (attributes: LensDocShape810): LensDocShape810 => { const newAttributes = cloneDeep(attributes); const datasourceLayers = newAttributes.state.datasourceStates.indexpattern.layers || {}; 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 e5754ee048b86..3f60eddb73e8e 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 @@ -25,9 +25,10 @@ import { } from './types'; import { layerTypes, LegacyMetricState } from '../../common'; import { Filter } from '@kbn/es-query'; +import { DataViewSpec } from '@kbn/data-views-plugin/common'; describe('Lens migrations', () => { - const migrations = getAllMigrations({}, {}); + const migrations = getAllMigrations({}, {}, {}); describe('7.7.0 missing dimensions in XY', () => { const context = {} as SavedObjectMigrationContext; @@ -1623,6 +1624,7 @@ describe('Lens migrations', () => { })); }, }, + {}, {} ); @@ -1649,6 +1651,61 @@ describe('Lens migrations', () => { }); }); + test('should properly apply a data view migration within a lens visualization', () => { + const migrationVersion = 'some-version'; + + const lensVisualizationDoc = { + attributes: { + state: { + adHocDataViews: { + abc: { + id: 'abc', + }, + def: { + id: 'def', + name: 'A name', + }, + }, + }, + }, + }; + + const migrationFunctionsObject = getAllMigrations( + {}, + { + [migrationVersion]: (dataView: DataViewSpec) => { + return { + ...dataView, + name: dataView.id, + }; + }, + }, + {} + ); + + const migratedLensDoc = migrationFunctionsObject[migrationVersion]( + lensVisualizationDoc as SavedObjectUnsanitizedDoc, + {} as SavedObjectMigrationContext + ); + + expect(migratedLensDoc).toEqual({ + attributes: { + state: { + adHocDataViews: { + abc: { + id: 'abc', + name: 'abc', + }, + def: { + id: 'def', + name: 'def', + }, + }, + }, + }, + }); + }); + test('should properly apply a custom visualization migration', () => { const migrationVersion = 'some-version'; @@ -1666,6 +1723,7 @@ describe('Lens migrations', () => { })); const migrationFunctionsObject = getAllMigrations( + {}, {}, { abc: () => ({ 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 cdc231db5f9ee..a363eba3eefd4 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -16,6 +16,7 @@ import { import type { Query, Filter } from '@kbn/es-query'; import { mergeSavedObjectMigrationMaps } from '@kbn/core/server'; import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common'; +import { DataViewSpec } from '@kbn/data-views-plugin/common'; import { PersistableFilter } from '../../common'; import { LensDocShapePost712, @@ -51,6 +52,7 @@ import { commonFixValueLabelsInXY, commonLockOldMetricVisSettings, commonPreserveOldLegendSizeDefault, + getLensDataViewMigrations, commonMigrateMetricIds, } from './common_migrations'; @@ -103,6 +105,7 @@ export interface LensDocShape { visualization: VisualizationState; query: Query; filters: PersistableFilter[]; + adHocDataViews?: Record; }; } @@ -542,12 +545,16 @@ const lensMigrations: SavedObjectMigrationMap = { export const getAllMigrations = ( filterMigrations: MigrateFunctionsObject, + dataViewMigrations: MigrateFunctionsObject, customVisualizationMigrations: CustomVisualizationMigrations ): SavedObjectMigrationMap => mergeSavedObjectMigrationMaps( mergeSavedObjectMigrationMaps( - lensMigrations, - getLensFilterMigrations(filterMigrations) as unknown as SavedObjectMigrationMap + mergeSavedObjectMigrationMaps( + lensMigrations, + getLensFilterMigrations(filterMigrations) as unknown as SavedObjectMigrationMap + ), + getLensCustomVisualizationMigrations(customVisualizationMigrations) ), - getLensCustomVisualizationMigrations(customVisualizationMigrations) + getLensDataViewMigrations(dataViewMigrations) as unknown as SavedObjectMigrationMap ); diff --git a/x-pack/plugins/lens/server/plugin.tsx b/x-pack/plugins/lens/server/plugin.tsx index 5671eb833ef66..baaa663616d2f 100644 --- a/x-pack/plugins/lens/server/plugin.tsx +++ b/x-pack/plugins/lens/server/plugin.tsx @@ -20,6 +20,7 @@ import { TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; import { EmbeddableSetup } from '@kbn/embeddable-plugin/server'; +import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common'; import { setupRoutes } from './routes'; import { getUiSettings } from './ui_settings'; import { setupSavedObjects } from './saved_objects'; @@ -71,6 +72,7 @@ export class LensServerPlugin implements Plugin, fromDate: schema.maybe(schema.string()), toDate: schema.maybe(schema.string()), timeFieldName: schema.maybe(schema.string()), + spec: schema.object({}, { unknowns: 'allow' }), }), }, }, @@ -110,6 +111,7 @@ async function fetchFieldExistence({ fromDate, toDate, timeFieldName, + spec, includeFrozen, useSampling, }: { @@ -120,6 +122,7 @@ async function fetchFieldExistence({ fromDate?: string; toDate?: string; timeFieldName?: string; + spec?: DataViewSpec; includeFrozen: boolean; useSampling: boolean; }) { @@ -132,13 +135,17 @@ async function fetchFieldExistence({ fromDate, toDate, timeFieldName, + spec, includeFrozen, }); } const uiSettingsClient = (await context.core).uiSettings.client; const metaFields: string[] = await uiSettingsClient.get(UI_SETTINGS.META_FIELDS); - const dataView = await dataViewsService.get(indexPatternId); + const dataView = + spec && Object.keys(spec).length !== 0 + ? await dataViewsService.create(spec) + : await dataViewsService.get(indexPatternId); const allFields = buildFieldList(dataView, metaFields); const existingFieldList = await dataViewsService.getFieldsForIndexPattern(dataView, { // filled in by data views service @@ -159,6 +166,7 @@ async function legacyFetchFieldExistenceSampling({ fromDate, toDate, timeFieldName, + spec, includeFrozen, }: { indexPatternId: string; @@ -168,11 +176,15 @@ async function legacyFetchFieldExistenceSampling({ fromDate?: string; toDate?: string; timeFieldName?: string; + spec?: DataViewSpec; includeFrozen: boolean; }) { const coreContext = await context.core; const metaFields: string[] = await coreContext.uiSettings.client.get(UI_SETTINGS.META_FIELDS); - const indexPattern = await dataViewsService.get(indexPatternId); + const indexPattern = + spec && Object.keys(spec).length !== 0 + ? await dataViewsService.create(spec) + : await dataViewsService.get(indexPatternId); const fields = buildFieldList(indexPattern, metaFields); const runtimeMappings = indexPattern.getRuntimeMappings(); diff --git a/x-pack/plugins/lens/server/saved_objects.ts b/x-pack/plugins/lens/server/saved_objects.ts index fdae8df746db8..b5afa19c31119 100644 --- a/x-pack/plugins/lens/server/saved_objects.ts +++ b/x-pack/plugins/lens/server/saved_objects.ts @@ -6,6 +6,7 @@ */ import { CoreSetup } from '@kbn/core/server'; +import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common'; import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common'; import { getEditPath } from '../common'; import { getAllMigrations } from './migrations/saved_object_migrations'; @@ -31,7 +32,12 @@ export function setupSavedObjects( uiCapabilitiesPath: 'visualize.show', }), }, - migrations: () => getAllMigrations(getFilterMigrations(), customVisualizationMigrations), + migrations: () => + getAllMigrations( + getFilterMigrations(), + DataViewPersistableStateService.getAllMigrations(), + customVisualizationMigrations + ), mappings: { properties: { title: { diff --git a/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts b/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts new file mode 100644 index 0000000000000..fb6d56168490e --- /dev/null +++ b/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts @@ -0,0 +1,149 @@ +/* + * 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 { DebugState } from '@elastic/charts'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects([ + 'visualize', + 'lens', + 'header', + 'unifiedSearch', + 'dashboard', + 'timeToVisualize', + ]); + const elasticChart = getService('elasticChart'); + const fieldEditor = getService('fieldEditor'); + const retry = getService('retry'); + + const expectedData = [ + { x: '97.220.3.248', y: 19755 }, + { x: '169.228.188.120', y: 18994 }, + { x: '78.83.247.30', y: 17246 }, + { x: '226.82.228.233', y: 15687 }, + { x: '93.28.27.24', y: 15614.33 }, + { x: 'Other', y: 5722.77 }, + ]; + function assertMatchesExpectedData(state: DebugState) { + expect( + state.bars![0].bars.map((bar) => ({ + x: bar.x, + y: Math.floor(bar.y * 100) / 100, + })) + ).to.eql(expectedData); + } + + async function setupAdHocDataView() { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await elasticChart.setNewChartUiDebugFlag(true); + await PageObjects.lens.createAdHocDataView('*stash*'); + retry.try(async () => { + const selectedPattern = await PageObjects.lens.getDataPanelIndexPattern(); + expect(selectedPattern).to.eql('*stash*'); + }); + } + + describe('lens ad hoc data view tests', () => { + it('should allow building a chart based on ad hoc data view', async () => { + await setupAdHocDataView(); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'ip', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + const data = await PageObjects.lens.getCurrentChartDebugState('xyVisChart'); + assertMatchesExpectedData(data!); + await PageObjects.lens.removeLayer(); + }); + + it('should allow adding and using a field', async () => { + await PageObjects.lens.switchToVisualization('lnsDatatable'); + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit('abc')"); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.searchField('runtime'); + await PageObjects.lens.waitForField('runtimefield'); + await PageObjects.lens.dragFieldToWorkspace('runtimefield'); + }); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal( + 'Top 5 values of runtimefield' + ); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('abc'); + await PageObjects.lens.removeLayer(); + }); + + it('should allow switching to another data view and back', async () => { + await PageObjects.lens.switchDataPanelIndexPattern('logstash-*'); + await PageObjects.lens.waitForFieldMissing('runtimefield'); + await PageObjects.lens.switchDataPanelIndexPattern('*stash*'); + await PageObjects.lens.waitForField('runtimefield'); + }); + + it('should allow removing a field', async () => { + await PageObjects.lens.clickField('runtimefield'); + await PageObjects.lens.removeField(); + await fieldEditor.confirmDelete(); + await PageObjects.lens.waitForFieldMissing('runtimefield'); + }); + + it('should allow adding an ad-hoc chart to a dashboard', async () => { + await PageObjects.lens.switchToVisualization('lnsMetric'); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsMetric_primaryMetricDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.waitForVisualization('mtrVis'); + const metricData = await PageObjects.lens.getMetricVisualizationData(); + expect(metricData[0].value).to.eql('5.73K'); + expect(metricData[0].title).to.eql('Average of bytes'); + await PageObjects.lens.save('New Lens from Modal', false, false, false, 'new'); + + await PageObjects.dashboard.waitForRenderComplete(); + expect(metricData[0].value).to.eql('5.73K'); + + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(1); + + await PageObjects.timeToVisualize.resetNewDashboard(); + }); + + it('should allow saving the ad-hoc chart into a saved object', async () => { + await setupAdHocDataView(); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.switchToVisualization('lnsMetric'); + + await PageObjects.lens.waitForVisualization('mtrVis'); + await PageObjects.lens.save('Lens with adhoc data view'); + await PageObjects.lens.waitForVisualization('mtrVis'); + const metricData = await PageObjects.lens.getMetricVisualizationData(); + expect(metricData[0].value).to.eql('5.73K'); + expect(metricData[0].title).to.eql('Average of bytes'); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/group1/index.ts b/x-pack/test/functional/apps/lens/group1/index.ts index 35030e3636c96..717f33c60181e 100644 --- a/x-pack/test/functional/apps/lens/group1/index.ts +++ b/x-pack/test/functional/apps/lens/group1/index.ts @@ -73,6 +73,7 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext loadTestFile(require.resolve('./smokescreen')); } else { loadTestFile(require.resolve('./smokescreen')); + loadTestFile(require.resolve('./ad_hoc_data_view')); loadTestFile(require.resolve('./persistent_context')); loadTestFile(require.resolve('./table_dashboard')); loadTestFile(require.resolve('./table')); diff --git a/x-pack/test/functional/apps/lens/group3/metric.ts b/x-pack/test/functional/apps/lens/group3/metric.ts index acffcb2285731..6b311c65f6fef 100644 --- a/x-pack/test/functional/apps/lens/group3/metric.ts +++ b/x-pack/test/functional/apps/lens/group3/metric.ts @@ -6,52 +6,18 @@ */ import expect from '@kbn/expect'; -import { WebElementWrapper } from '../../../../../../test/functional/services/lib/web_element_wrapper'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); - const findService = getService('find'); const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); const retry = getService('retry'); - const getMetricTiles = () => - findService.allByCssSelector('[data-test-subj="mtrVis"] .echChart li'); - - const getIfExists = async (selector: string, container: WebElementWrapper) => - (await findService.descendantExistsByCssSelector(selector, container)) - ? await container.findByCssSelector(selector) - : undefined; - - const getMetricDatum = async (tile: WebElementWrapper) => { - return { - title: await (await getIfExists('h2', tile))?.getVisibleText(), - subtitle: await (await getIfExists('.echMetricText__subtitle', tile))?.getVisibleText(), - extraText: await (await getIfExists('.echMetricText__extra', tile))?.getVisibleText(), - value: await (await getIfExists('.echMetricText__value', tile))?.getVisibleText(), - color: await (await getIfExists('.echMetric', tile))?.getComputedStyle('background-color'), - }; - }; - - const getMetricData = async () => { - const tiles = await getMetricTiles(); - const showingBar = Boolean(await findService.existsByCssSelector('.echSingleMetricProgress')); - - const metricData = []; - for (const tile of tiles) { - metricData.push({ - ...(await getMetricDatum(tile)), - showingBar, - }); - } - return metricData; - }; - const clickMetric = async (title: string) => { - const tiles = await getMetricTiles(); + const tiles = await PageObjects.lens.getMetricTiles(); for (const tile of tiles) { - const datum = await getMetricDatum(tile); + const datum = await PageObjects.lens.getMetricDatum(tile); if (datum.title === title) { await tile.click(); return; @@ -79,7 +45,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { field: 'bytes', }); - expect((await getMetricData()).length).to.be.equal(1); + expect((await PageObjects.lens.getMetricVisualizationData()).length).to.be.equal(1); await PageObjects.lens.configureDimension({ dimension: 'lnsMetric_breakdownByDimensionPanel > lns-empty-dimension', @@ -89,7 +55,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.waitForVisualization('mtrVis'); - expect(await getMetricData()).to.eql([ + expect(await PageObjects.lens.getMetricVisualizationData()).to.eql([ { title: '97.220.3.248', subtitle: 'Average of bytes', @@ -146,7 +112,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.waitForVisualization('mtrVis'); - expect((await getMetricData())[0].showingBar).to.be(true); + expect((await PageObjects.lens.getMetricVisualizationData())[0].showingBar).to.be(true); await PageObjects.lens.closeDimensionEditor(); await PageObjects.lens.removeDimension('lnsMetric_maxDimensionPanel'); @@ -179,7 +145,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.waitForVisualization('mtrVis'); - const data = await getMetricData(); + const data = await PageObjects.lens.getMetricVisualizationData(); expect(data.map(({ color }) => color)).to.be.eql(new Array(6).fill('rgba(0, 0, 0, 1)')); }); @@ -198,7 +164,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.waitForVisualization('mtrVis'); - const data = await getMetricData(); + const data = await PageObjects.lens.getMetricVisualizationData(); expect(data.map(({ color }) => color)).to.eql(expectedDynamicColors); }); @@ -213,7 +179,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.waitForVisualization('mtrVis'); - expect((await getMetricData()).map(({ color }) => color)).to.eql(expectedDynamicColors); // colors shouldn't change + expect( + (await PageObjects.lens.getMetricVisualizationData()).map(({ color }) => color) + ).to.eql(expectedDynamicColors); // colors shouldn't change await PageObjects.lens.closePaletteEditor(); await PageObjects.lens.closeDimensionEditor(); @@ -235,7 +203,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.closeDimensionEditor(); - const tiles = await getMetricTiles(); + const tiles = await await PageObjects.lens.getMetricTiles(); const lastTile = tiles[tiles.length - 1]; const initialPosition = await lastTile.getPosition(); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 7eddf00431fc4..2a96080e9d4f6 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -7,11 +7,13 @@ import expect from '@kbn/expect'; import { setTimeout as setTimeoutAsync } from 'timers/promises'; +import { WebElementWrapper } from '../../../../test/functional/services/lib/web_element_wrapper'; import { FtrProviderContext } from '../ftr_provider_context'; import { logWrapper } from './log_wrapper'; export function LensPageProvider({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); + const findService = getService('find'); const testSubjects = getService('testSubjects'); const retry = getService('retry'); const elasticChart = getService('elasticChart'); @@ -1111,6 +1113,47 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont // TODO: target dimensionTrigger color element after merging https://github.com/elastic/kibana/pull/76871 await testSubjects.getAttribute('~indexPattern-dimension-colorPicker', color); }, + async getMetricTiles() { + return findService.allByCssSelector('[data-test-subj="mtrVis"] .echChart li'); + }, + + async getMetricElementIfExists(selector: string, container: WebElementWrapper) { + return (await findService.descendantExistsByCssSelector(selector, container)) + ? await container.findByCssSelector(selector) + : undefined; + }, + + async getMetricDatum(tile: WebElementWrapper) { + return { + title: await (await this.getMetricElementIfExists('h2', tile))?.getVisibleText(), + subtitle: await ( + await this.getMetricElementIfExists('.echMetricText__subtitle', tile) + )?.getVisibleText(), + extraText: await ( + await this.getMetricElementIfExists('.echMetricText__extra', tile) + )?.getVisibleText(), + value: await ( + await this.getMetricElementIfExists('.echMetricText__value', tile) + )?.getVisibleText(), + color: await ( + await this.getMetricElementIfExists('.echMetric', tile) + )?.getComputedStyle('background-color'), + }; + }, + + async getMetricVisualizationData() { + const tiles = await this.getMetricTiles(); + const showingBar = Boolean(await findService.existsByCssSelector('.echSingleMetricProgress')); + + const metricData = []; + for (const tile of tiles) { + metricData.push({ + ...(await this.getMetricDatum(tile)), + showingBar, + }); + } + return metricData; + }, /** * Creates and saves a lens visualization from a dashboard @@ -1208,6 +1251,11 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await testSubjects.click('indexPattern-add-field'); }, + async createAdHocDataView(name: string) { + await testSubjects.click('lns-dataView-switch-link'); + await PageObjects.unifiedSearch.createNewDataView(name, true); + }, + /** resets visualization/layer or removes a layer */ async removeLayer() { await retry.try(async () => {