[Lens] Enables ad-hoc dataviews (#138732)

* [Lens] Enables ad-hoc dataviews

* Fix bug in legacy field existence logic

* lift ad hoc state to frame level and rename data view on incompatible change

* fix some tests

* migrate embedded ad hoc data views

* fix tests and inject/extract ad hoc data view references

* fix bugs and add functional tests

* fix unit tests

* do not show geo fields for ad hoc data views

* Fix functional test

* Refactor to use the new metric viz on the functional tests

Co-authored-by: Joe Reuter <johannes.reuter@elastic.co>
This commit is contained in:
Stratoula Kalafateli 2022-08-29 15:16:47 +03:00 committed by GitHub
parent d5cc164bd8
commit 255f24d595
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1080 additions and 353 deletions

View file

@ -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');
});
});

View file

@ -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,
},
};
});

View file

@ -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');
});
});

View file

@ -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) {

View file

@ -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}

View file

@ -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');
}
}

View file

@ -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,

View file

@ -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', () => {

View file

@ -10,6 +10,7 @@ Array [
"loadIndexPatternRefs": [Function],
"loadIndexPatterns": [Function],
"refreshExistingFields": [Function],
"replaceDataViewId": [Function],
"updateDataViewsState": [Function],
},
"lensInspector": Object {

View file

@ -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);

View file

@ -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<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
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]
);

View file

@ -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);

View file

@ -251,23 +251,20 @@ export const LensTopNavMenu = ({
(state: Partial<LensAppState>) => dispatch(setState(state)),
[dispatch]
);
const [indexPatterns, setIndexPatterns] = useState<DataView[]>([]);
const [currentIndexPattern, setCurrentIndexPattern] = useState<DataView>();
const [rejectedIndexPatterns, setRejectedIndexPatterns] = useState<string[]>([]);
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<DataView[]>([]);
const [currentIndexPattern, setCurrentIndexPattern] = useState<DataView>();
const [rejectedIndexPatterns, setRejectedIndexPatterns] = useState<string[]>([]);
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

View file

@ -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,

View file

@ -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;

View file

@ -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<string, DataViewSpec>;
},
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<string, DataViewSpec>;
},
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,
});

View file

@ -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 indexPatternsCache = this.indexPatterns.reduce(
const allIndexPatterns = [...this.indexPatterns, ...adHocDataviews];
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');

View file

@ -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 (
<ToolbarButton
title={title}
onClick={() => setPopoverIsOpen(!isPopoverOpen)}
fullWidth
{...colorProp}
{...rest}
>
{label}
</ToolbarButton>
);
};
return (
<>
<EuiPopover
panelClassName="lnsChangeIndexPatternPopover"
button={createTrigger()}
panelProps={{
['data-test-subj']: 'lnsChangeIndexPatternPopover',
}}
isOpen={isPopoverOpen}
closePopover={() => setPopoverIsOpen(false)}
display="block"
panelPaddingSize="none"
ownFocus
>
<div>
<EuiPopoverTitle paddingSize="s">
{i18n.translate('xpack.lens.indexPattern.changeDataViewTitle', {
defaultMessage: 'Data view',
})}
</EuiPopoverTitle>
<DataViewsList
dataViewsList={indexPatternRefs}
onChangeDataView={(newId) => {
onChangeIndexPattern(newId);
setPopoverIsOpen(false);
}}
currentDataViewId={indexPatternId}
selectableProps={selectableProps}
/>
</div>
</EuiPopover>
</>
);
}

View file

@ -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(

View file

@ -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]) {

View file

@ -49,16 +49,18 @@ function getLayerReferenceName(layerId: string) {
export function extractReferences({ layers }: IndexPatternPrivateState) {
const savedObjectReferences: SavedObjectReference[] = [];
const persistableLayers: Record<string, Omit<IndexPatternLayer, 'indexPatternId'>> = {};
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);

View file

@ -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',

View file

@ -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';

View file

@ -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

View file

@ -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<DataViewsContract, 'get' | 'getIdsWithTitle'>,
create: jest.fn(),
} as unknown as Pick<DataViewsContract, 'get' | 'getIdsWithTitle' | 'create'>,
});
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<DataViewsContract, 'get' | 'getIdsWithTitle'>,
create: jest.fn(),
} as unknown as Pick<DataViewsContract, 'get' | 'getIdsWithTitle' | 'create'>,
});
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<DataViewsContract, 'get' | 'getIdsWithTitle'>;
} as unknown as Pick<DataViewsContract, 'get' | 'getIdsWithTitle' | 'create'>;
const cache = await loadIndexPatterns({
cache: {},
patterns: ['1', '2'],
@ -234,7 +239,7 @@ describe('loader', () => {
throw err;
}),
getIdsWithTitle: jest.fn(),
} as unknown as Pick<DataViewsContract, 'get' | 'getIdsWithTitle'>,
} as unknown as Pick<DataViewsContract, 'get' | 'getIdsWithTitle' | 'create'>,
onError,
});

View file

@ -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<DataViewsContract, 'get' | 'getIdsWithTitle'>;
type MinimalDataViewsContract = Pick<DataViewsContract, 'get' | 'getIdsWithTitle' | 'create'>;
/**
* 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<string, DataViewSpec>
): Promise<IndexPatternRef[]> {
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<string, IndexPattern>;
adHocDataViews?: Record<string, DataViewSpec>;
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<ExistingFields>;

View file

@ -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<DataViewsContract, 'get' | 'getIdsWithTitle'>;
create: jest.fn(),
} as unknown as Pick<DataViewsContract, 'get' | 'getIdsWithTitle' | 'create'>;
}

View file

@ -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<CoreStart, 'http' | 'notifications'>;
dataViews: DataViewsContract;
uiSettings: IUiSettingsClient;
uiActions: UiActionsStart;
updateIndexPatterns: (
newState: Partial<DataViewsState>,
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<void>;
replaceDataViewId: (newDataView: DataView) => Promise<void>;
/**
* 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) =>

View file

@ -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(),

View file

@ -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<IndexPatternServiceProps> = {}): IndexPatternServiceAPI {
return createIndexPatternService({ core, uiSettings, updateIndexPatterns, dataViews });
return createIndexPatternService({
core,
uiSettings,
updateIndexPatterns,
replaceIndexPattern,
dataViews,
uiActions,
});
}

View file

@ -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(),

View file

@ -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<string, DataViewSpec>;
internalReferences?: SavedObjectReference[];
};
references: SavedObjectReference[];
}

View file

@ -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 }

View file

@ -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<Partial<DataViewsState>>(
'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<LensAppState> = { 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<LensAppState> = {
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,
{

View file

@ -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<string, unknown> = {};
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,
},
};
}

View file

@ -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<T = unknown, P = unknown> {
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<T = unknown> {
/** 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<T = unknown> {
* 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.
*/

View file

@ -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,
});
}

View file

@ -235,6 +235,7 @@ export const getLegacyMetricVisualization = ({
groups: [
{
groupId: 'metric',
dataTestSubj: 'lnsLegacyMetric_metricDimensionPanel',
paramEditorCustomProps: {
headingLabel: i18n.translate('xpack.lens.metric.headingLabel', {
defaultMessage: 'Value',

View file

@ -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);

View file

@ -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<VisStatePre715> };
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<VisState716> };
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<VisState810>;
};
let migratedLensState = commonSetLastValueShowArrayValues(lensState.attributes);
migratedLensState = commonEnhanceTableRowHeight(
migratedLensState as LensDocShape810<VisState810>
);
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<VisStatePre715>;
};
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<VisState716> };
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<VisState810>;
};
let migratedLensState = commonSetLastValueShowArrayValues(lensState.attributes);
migratedLensState = commonEnhanceTableRowHeight(
migratedLensState as LensDocShape810<VisState810>
);
migratedLensState = commonSetIncludeEmptyRowsDateHistogram(migratedLensState);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
'8.3.0': (state) => {
const lensState = state as unknown as { attributes: LensDocShape810<VisState810> };
let migratedLensState = commonLockOldMetricVisSettings(lensState.attributes);
migratedLensState = commonPreserveOldLegendSizeDefault(migratedLensState);
migratedLensState = commonFixValueLabelsInXY(
migratedLensState as LensDocShape810<VisStatePre830>
);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
'8.5.0': (state) => {
const lensState = state as unknown as { attributes: LensDocShape840<VisState840> };
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<VisState810> };
let migratedLensState = commonLockOldMetricVisSettings(lensState.attributes);
migratedLensState = commonPreserveOldLegendSizeDefault(migratedLensState);
migratedLensState = commonFixValueLabelsInXY(
migratedLensState as LensDocShape810<VisStatePre830>
);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
'8.5.0': (state) => {
const lensState = state as unknown as { attributes: LensDocShape840<VisState840> };
const migratedLensState = commonMigrateMetricIds(lensState.attributes);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
}),
getLensCustomVisualizationMigrations(customVisualizationMigrations)
),
getLensDataViewMigrations(getDataViewMigrations())
),
extract,
inject,

View file

@ -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',
},
});
});
});
});

View file

@ -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 || {};

View file

@ -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: () => ({

View file

@ -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<VisualizationState = unknown> {
visualization: VisualizationState;
query: Query;
filters: PersistableFilter[];
adHocDataViews?: Record<string, DataViewSpec>;
};
}
@ -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
);

View file

@ -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<LensServerPluginSetup, {}, {}, {
const lensEmbeddableFactory = makeLensEmbeddableFactory(
getFilterMigrations,
DataViewPersistableStateService.getAllMigrations.bind(DataViewPersistableStateService),
this.customVisualizationMigrations
);
plugins.embeddable.registerEmbeddableFactory(lensEmbeddableFactory());

View file

@ -12,7 +12,7 @@ import { schema } from '@kbn/config-schema';
import { RequestHandlerContext, ElasticsearchClient } from '@kbn/core/server';
import { CoreSetup, Logger } from '@kbn/core/server';
import { RuntimeField } from '@kbn/data-views-plugin/common';
import { DataViewsService, DataView, FieldSpec } from '@kbn/data-views-plugin/common';
import { DataViewsService, DataView, FieldSpec, DataViewSpec } from '@kbn/data-views-plugin/common';
import { UI_SETTINGS } from '@kbn/data-plugin/server';
import { BASE_API_URL } from '../../common';
import { FIELD_EXISTENCE_SETTING } from '../ui_settings';
@ -51,6 +51,7 @@ export async function existingFieldsRoute(setup: CoreSetup<PluginStartContract>,
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();

View file

@ -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: {

View file

@ -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');
});
});
}

View file

@ -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'));

View file

@ -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();

View file

@ -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 () => {