[Lens] Register saved object references (#74523)

This commit is contained in:
Joe Reuter 2020-08-21 18:08:25 +02:00 committed by GitHub
parent 471b114089
commit 86f73cb0c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1609 additions and 659 deletions

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { FilterMeta, Filter } from 'src/plugins/data/common';
export interface ExistingFields {
indexPatternTitle: string;
existingFieldNames: string[];
@ -13,3 +15,11 @@ export interface DateRange {
fromDate: string;
toDate: string;
}
export interface PersistableFilterMeta extends FilterMeta {
indexRefName?: string;
}
export interface PersistableFilter extends Filter {
meta: PersistableFilterMeta;
}

View file

@ -33,7 +33,7 @@ import { navigationPluginMock } from '../../../../../src/plugins/navigation/publ
import { TopNavMenuData } from '../../../../../src/plugins/navigation/public';
import { coreMock } from 'src/core/public/mocks';
jest.mock('../persistence');
jest.mock('../editor_frame_service/editor_frame/expression_helpers');
jest.mock('src/core/public');
jest.mock('../../../../../src/plugins/saved_objects/public', () => {
// eslint-disable-next-line no-shadow
@ -284,11 +284,11 @@ describe('Lens App', () => {
(defaultArgs.docStorage.load as jest.Mock).mockResolvedValue({
id: '1234',
title: 'Daaaaaaadaumching!',
expression: 'valid expression',
state: {
query: 'fake query',
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
filters: [],
},
references: [],
});
await act(async () => {
instance.setProps({ docId: '1234' });
@ -346,12 +346,11 @@ describe('Lens App', () => {
args.editorFrame = frame;
(args.docStorage.load as jest.Mock).mockResolvedValue({
id: '1234',
expression: 'valid expression',
state: {
query: 'fake query',
filters: [{ query: { match_phrase: { src: 'test' } } }],
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
},
references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }],
});
instance = mount(<App {...args} />);
@ -375,15 +374,13 @@ describe('Lens App', () => {
expect(frame.mount).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({
doc: {
doc: expect.objectContaining({
id: '1234',
expression: 'valid expression',
state: {
state: expect.objectContaining({
query: 'fake query',
filters: [{ query: { match_phrase: { src: 'test' } } }],
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
},
},
}),
}),
})
);
});
@ -444,7 +441,6 @@ describe('Lens App', () => {
expression: 'valid expression',
state: {
query: 'kuery',
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
},
} as jest.ResolvedValue<Document>);
});
@ -467,7 +463,12 @@ describe('Lens App', () => {
}
async function save({
lastKnownDoc = { expression: 'kibana 3' },
lastKnownDoc = {
references: [],
state: {
filters: [],
},
},
initialDocId,
...saveProps
}: SaveProps & {
@ -481,16 +482,14 @@ describe('Lens App', () => {
args.editorFrame = frame;
(args.docStorage.load as jest.Mock).mockResolvedValue({
id: '1234',
expression: 'kibana',
references: [],
state: {
query: 'fake query',
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
filters: [],
},
});
(args.docStorage.save as jest.Mock).mockImplementation(async ({ id }) => ({
id: id || 'aaa',
expression: 'kibana 2',
}));
await act(async () => {
@ -508,6 +507,7 @@ describe('Lens App', () => {
onChange({
filterableIndexPatterns: [],
doc: { id: initialDocId, ...lastKnownDoc } as Document,
isSaveable: true,
})
);
@ -541,7 +541,8 @@ describe('Lens App', () => {
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: 'will save this', expression: 'valid expression' } as unknown) as Document,
doc: ({ id: 'will save this' } as unknown) as Document,
isSaveable: true,
})
);
instance.update();
@ -560,7 +561,8 @@ describe('Lens App', () => {
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: 'will save this', expression: 'valid expression' } as unknown) as Document,
doc: ({ id: 'will save this' } as unknown) as Document,
isSaveable: true,
})
);
instance.update();
@ -575,11 +577,12 @@ describe('Lens App', () => {
newTitle: 'hello there',
});
expect(args.docStorage.save).toHaveBeenCalledWith({
id: undefined,
title: 'hello there',
expression: 'kibana 3',
});
expect(args.docStorage.save).toHaveBeenCalledWith(
expect.objectContaining({
id: undefined,
title: 'hello there',
})
);
expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true);
@ -595,11 +598,12 @@ describe('Lens App', () => {
newTitle: 'hello there',
});
expect(args.docStorage.save).toHaveBeenCalledWith({
id: undefined,
title: 'hello there',
expression: 'kibana 3',
});
expect(args.docStorage.save).toHaveBeenCalledWith(
expect.objectContaining({
id: undefined,
title: 'hello there',
})
);
expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true);
@ -615,11 +619,12 @@ describe('Lens App', () => {
newTitle: 'hello there',
});
expect(args.docStorage.save).toHaveBeenCalledWith({
id: '1234',
title: 'hello there',
expression: 'kibana 3',
});
expect(args.docStorage.save).toHaveBeenCalledWith(
expect.objectContaining({
id: '1234',
title: 'hello there',
})
);
expect(args.redirectTo).not.toHaveBeenCalled();
@ -639,7 +644,8 @@ describe('Lens App', () => {
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: undefined, expression: 'new expression' } as unknown) as Document,
doc: ({ id: undefined } as unknown) as Document,
isSaveable: true,
})
);
@ -663,11 +669,12 @@ describe('Lens App', () => {
newTitle: 'hello there',
});
expect(args.docStorage.save).toHaveBeenCalledWith({
expression: 'kibana 3',
id: undefined,
title: 'hello there',
});
expect(args.docStorage.save).toHaveBeenCalledWith(
expect.objectContaining({
id: undefined,
title: 'hello there',
})
);
expect(args.redirectTo).toHaveBeenCalledWith('aaa', true, true);
});
@ -717,7 +724,8 @@ describe('Lens App', () => {
await act(async () =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: '123', expression: 'valid expression' } as unknown) as Document,
doc: ({ id: '123' } as unknown) as Document,
isSaveable: true,
})
);
instance.update();
@ -756,7 +764,8 @@ describe('Lens App', () => {
await act(async () =>
onChange({
filterableIndexPatterns: [],
doc: ({ expression: 'valid expression' } as unknown) as Document,
doc: ({} as unknown) as Document,
isSaveable: true,
})
);
instance.update();
@ -779,7 +788,6 @@ describe('Lens App', () => {
expression: 'valid expression',
state: {
query: 'kuery',
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
},
} as jest.ResolvedValue<Document>);
});
@ -824,8 +832,9 @@ describe('Lens App', () => {
await act(async () => {
onChange({
filterableIndexPatterns: [{ id: '1', title: 'newIndex' }],
doc: ({ id: undefined, expression: 'valid expression' } as unknown) as Document,
filterableIndexPatterns: ['1'],
doc: ({ id: undefined } as unknown) as Document,
isSaveable: true,
});
});
@ -842,8 +851,9 @@ describe('Lens App', () => {
await act(async () => {
onChange({
filterableIndexPatterns: [{ id: '2', title: 'second index' }],
doc: ({ id: undefined, expression: 'valid expression' } as unknown) as Document,
filterableIndexPatterns: ['2'],
doc: ({ id: undefined } as unknown) as Document,
isSaveable: true,
});
});
@ -1078,11 +1088,11 @@ describe('Lens App', () => {
(defaultArgs.docStorage.load as jest.Mock).mockResolvedValue({
id: '1234',
title: 'My cool doc',
expression: 'valid expression',
state: {
query: 'kuery',
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] },
filters: [],
},
references: [],
} as jest.ResolvedValue<Document>);
});
@ -1114,7 +1124,12 @@ describe('Lens App', () => {
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: undefined, expression: 'valid expression' } as unknown) as Document,
doc: ({
id: undefined,
references: [],
} as unknown) as Document,
isSaveable: true,
})
);
instance.update();
@ -1135,7 +1150,8 @@ describe('Lens App', () => {
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: undefined, expression: 'valid expression' } as unknown) as Document,
doc: ({ id: undefined, state: {} } as unknown) as Document,
isSaveable: true,
})
);
instance.update();
@ -1159,7 +1175,12 @@ describe('Lens App', () => {
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: '1234', expression: 'different expression' } as unknown) as Document,
doc: ({
id: '1234',
references: [],
} as unknown) as Document,
isSaveable: true,
})
);
instance.update();
@ -1183,7 +1204,16 @@ describe('Lens App', () => {
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: '1234', expression: 'valid expression' } as unknown) as Document,
doc: ({
id: '1234',
title: 'My cool doc',
references: [],
state: {
query: 'kuery',
filters: [],
},
} as unknown) as Document,
isSaveable: true,
})
);
instance.update();
@ -1207,7 +1237,8 @@ describe('Lens App', () => {
act(() =>
onChange({
filterableIndexPatterns: [],
doc: ({ id: '1234', expression: null } as unknown) as Document,
doc: ({ id: '1234', references: [] } as unknown) as Document,
isSaveable: true,
})
);
instance.update();

View file

@ -28,7 +28,7 @@ import {
OnSaveProps,
checkForDuplicateTitle,
} from '../../../../../src/plugins/saved_objects/public';
import { Document, SavedObjectStore } from '../persistence';
import { Document, SavedObjectStore, injectFilterReferences } from '../persistence';
import { EditorFrameInstance } from '../types';
import { NativeRenderer } from '../native_renderer';
import { trackUiEvent } from '../lens_ui_telemetry';
@ -57,6 +57,7 @@ interface State {
query: Query;
filters: Filter[];
savedQuery?: SavedQuery;
isSaveable: boolean;
}
export function App({
@ -100,6 +101,7 @@ export function App({
originatingApp,
filters: data.query.filterManager.getFilters(),
indicateNoData: false,
isSaveable: false,
};
});
@ -122,11 +124,7 @@ export function App({
const { lastKnownDoc } = state;
const isSaveable =
lastKnownDoc &&
lastKnownDoc.expression &&
lastKnownDoc.expression.length > 0 &&
core.application.capabilities.visualize.save;
const savingPermitted = state.isSaveable && core.application.capabilities.visualize.save;
useEffect(() => {
// Clear app-specific filters when navigating to Lens. Necessary because Lens
@ -177,15 +175,34 @@ export function App({
history,
]);
const getLastKnownDocWithoutPinnedFilters = useCallback(
function () {
if (!lastKnownDoc) return undefined;
const [pinnedFilters, appFilters] = _.partition(
injectFilterReferences(lastKnownDoc.state?.filters || [], lastKnownDoc.references),
esFilters.isFilterPinned
);
return pinnedFilters?.length
? {
...lastKnownDoc,
state: {
...lastKnownDoc.state,
filters: appFilters,
},
}
: lastKnownDoc;
},
[lastKnownDoc]
);
useEffect(() => {
onAppLeave((actions) => {
// Confirm when the user has made any changes to an existing doc
// or when the user has configured something without saving
if (
core.application.capabilities.visualize.save &&
(state.persistedDoc?.expression
? !_.isEqual(lastKnownDoc?.expression, state.persistedDoc.expression)
: lastKnownDoc?.expression)
!_.isEqual(state.persistedDoc?.state, getLastKnownDocWithoutPinnedFilters()?.state) &&
(state.isSaveable || state.persistedDoc)
) {
return actions.confirm(
i18n.translate('xpack.lens.app.unsavedWorkMessage', {
@ -199,7 +216,14 @@ export function App({
return actions.default();
}
});
}, [lastKnownDoc, onAppLeave, state.persistedDoc, core.application.capabilities.visualize.save]);
}, [
lastKnownDoc,
onAppLeave,
state.persistedDoc,
state.isSaveable,
core.application.capabilities.visualize.save,
getLastKnownDocWithoutPinnedFilters,
]);
// Sync Kibana breadcrumbs any time the saved document's title changes
useEffect(() => {
@ -248,13 +272,17 @@ export function App({
.load(docId)
.then((doc) => {
getAllIndexPatterns(
doc.state.datasourceMetaData.filterableIndexPatterns,
_.uniq(
doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)
),
data.indexPatterns,
core.notifications
)
.then((indexPatterns) => {
// Don't overwrite any pinned filters
data.query.filterManager.setAppFilters(doc.state.filters);
data.query.filterManager.setAppFilters(
injectFilterReferences(doc.state.filters, doc.references)
);
setState((s) => ({
...s,
isLoading: false,
@ -264,13 +292,13 @@ export function App({
indexPatternsForTopNav: indexPatterns,
}));
})
.catch(() => {
.catch((e) => {
setState((s) => ({ ...s, isLoading: false }));
redirectTo();
});
})
.catch(() => {
.catch((e) => {
setState((s) => ({ ...s, isLoading: false }));
core.notifications.toasts.addDanger(
@ -306,22 +334,9 @@ export function App({
if (!lastKnownDoc) {
return;
}
const [pinnedFilters, appFilters] = _.partition(
lastKnownDoc.state?.filters,
esFilters.isFilterPinned
);
const lastDocWithoutPinned = pinnedFilters?.length
? {
...lastKnownDoc,
state: {
...lastKnownDoc.state,
filters: appFilters,
},
}
: lastKnownDoc;
const doc = {
...lastDocWithoutPinned,
...getLastKnownDocWithoutPinnedFilters()!,
description: saveProps.newDescription,
id: saveProps.newCopyOnSave ? undefined : lastKnownDoc.id,
title: saveProps.newTitle,
@ -411,7 +426,7 @@ export function App({
emphasize: true,
iconType: 'check',
run: () => {
if (isSaveable && lastKnownDoc) {
if (savingPermitted) {
runSave({
newTitle: lastKnownDoc.title,
newCopyOnSave: false,
@ -421,7 +436,7 @@ export function App({
}
},
testId: 'lnsApp_saveAndReturnButton',
disableButton: !isSaveable,
disableButton: !savingPermitted,
},
]
: []),
@ -436,12 +451,12 @@ export function App({
}),
emphasize: !state.originatingApp || !lastKnownDoc?.id,
run: () => {
if (isSaveable && lastKnownDoc) {
if (savingPermitted) {
setState((s) => ({ ...s, isSaveModalVisible: true }));
}
},
testId: 'lnsApp_saveButton',
disableButton: !isSaveable,
disableButton: !savingPermitted,
},
]}
data-test-subj="lnsApp_topNav"
@ -522,7 +537,10 @@ export function App({
doc: state.persistedDoc,
onError,
showNoDataPopover,
onChange: ({ filterableIndexPatterns, doc }) => {
onChange: ({ filterableIndexPatterns, doc, isSaveable }) => {
if (isSaveable !== state.isSaveable) {
setState((s) => ({ ...s, isSaveable }));
}
if (!_.isEqual(state.persistedDoc, doc)) {
setState((s) => ({ ...s, lastKnownDoc: doc }));
}
@ -530,8 +548,8 @@ export function App({
// Update the cached index patterns if the user made a change to any of them
if (
state.indexPatternsForTopNav.length !== filterableIndexPatterns.length ||
filterableIndexPatterns.find(
({ id }) =>
filterableIndexPatterns.some(
(id) =>
!state.indexPatternsForTopNav.find((indexPattern) => indexPattern.id === id)
)
) {
@ -573,12 +591,12 @@ export function App({
}
export async function getAllIndexPatterns(
ids: Array<{ id: string }>,
ids: string[],
indexPatternsService: IndexPatternsContract,
notifications: NotificationsStart
): Promise<IndexPatternInstance[]> {
try {
return await Promise.all(ids.map(({ id }) => indexPatternsService.get(id)));
return await Promise.all(ids.map((id) => indexPatternsService.get(id)));
} catch (e) {
notifications.toasts.addDanger(
i18n.translate('xpack.lens.app.indexPatternLoadingError', {

View file

@ -50,20 +50,6 @@ describe('Datatable Visualization', () => {
});
});
describe('#getPersistableState', () => {
it('should persist the internal state', () => {
const expectedState: DatatableVisualizationState = {
layers: [
{
layerId: 'baz',
columns: ['a', 'b', 'c'],
},
],
};
expect(datatableVisualization.getPersistableState(expectedState)).toEqual(expectedState);
});
});
describe('#getLayerIds', () => {
it('return the layer ids', () => {
const state: DatatableVisualizationState = {
@ -340,7 +326,10 @@ describe('Datatable Visualization', () => {
label: 'label',
});
const expression = datatableVisualization.toExpression({ layers: [layer] }, frame) as Ast;
const expression = datatableVisualization.toExpression(
{ layers: [layer] },
frame.datasourceLayers
) as Ast;
const tableArgs = buildExpression(expression).findFunction('lens_datatable_columns');
expect(tableArgs).toHaveLength(1);

View file

@ -25,10 +25,7 @@ function newLayerState(layerId: string): LayerState {
};
}
export const datatableVisualization: Visualization<
DatatableVisualizationState,
DatatableVisualizationState
> = {
export const datatableVisualization: Visualization<DatatableVisualizationState> = {
id: 'lnsDatatable',
visualizationTypes: [
@ -75,8 +72,6 @@ export const datatableVisualization: Visualization<
);
},
getPersistableState: (state) => state,
getSuggestions({
table,
state,
@ -186,9 +181,9 @@ export const datatableVisualization: Visualization<
};
},
toExpression(state, frame): Ast {
toExpression(state, datasourceLayers): Ast {
const layer = state.layers[0];
const datasource = frame.datasourceLayers[layer.layerId];
const datasource = datasourceLayers[layer.layerId];
const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId);
// When we add a column it could be empty, and therefore have no order
const sortedColumns = Array.from(new Set(originalOrder.concat(layer.columns)));

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Ast } from '@kbn/interpreter/common';
export function buildExpression(): Ast {
return {
type: 'expression',
chain: [{ type: 'function', function: 'test', arguments: {} }],
};
}

View file

@ -124,7 +124,6 @@ export function LayerPanel(
const nextPublicAPI = layerDatasource.getPublicAPI({
state: newState,
layerId,
dateRange: props.framePublicAPI.dateRange,
});
const nextTable = new Set(
nextPublicAPI.getTableSpec().map(({ columnId }) => columnId)

View file

@ -170,25 +170,22 @@ describe('editor_frame', () => {
doc={{
visualizationType: 'testVis',
title: '',
expression: '',
state: {
datasourceStates: {
testDatasource: datasource1State,
testDatasource2: datasource2State,
},
visualization: {},
datasourceMetaData: {
filterableIndexPatterns: [],
},
query: { query: '', language: 'lucene' },
filters: [],
},
references: [],
}}
/>
);
});
expect(mockDatasource.initialize).toHaveBeenCalledWith(datasource1State);
expect(mockDatasource2.initialize).toHaveBeenCalledWith(datasource2State);
expect(mockDatasource.initialize).toHaveBeenCalledWith(datasource1State, []);
expect(mockDatasource2.initialize).toHaveBeenCalledWith(datasource2State, []);
expect(mockDatasource3.initialize).not.toHaveBeenCalled();
});
@ -425,21 +422,6 @@ describe('editor_frame', () => {
"function": "kibana",
"type": "function",
},
Object {
"arguments": Object {
"filters": Array [
"[]",
],
"query": Array [
"{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}",
],
"timeRange": Array [
"{\\"from\\":\\"\\",\\"to\\":\\"\\"}",
],
},
"function": "kibana_context",
"type": "function",
},
Object {
"arguments": Object {
"layerIds": Array [
@ -499,19 +481,16 @@ describe('editor_frame', () => {
doc={{
visualizationType: 'testVis',
title: '',
expression: '',
state: {
datasourceStates: {
testDatasource: {},
testDatasource2: {},
},
visualization: {},
datasourceMetaData: {
filterableIndexPatterns: [],
},
query: { query: '', language: 'lucene' },
filters: [],
},
references: [],
}}
/>
);
@ -535,21 +514,6 @@ describe('editor_frame', () => {
"function": "kibana",
"type": "function",
},
Object {
"arguments": Object {
"filters": Array [
"[]",
],
"query": Array [
"{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}",
],
"timeRange": Array [
"{\\"from\\":\\"\\",\\"to\\":\\"\\"}",
],
},
"function": "kibana_context",
"type": "function",
},
Object {
"arguments": Object {
"layerIds": Array [
@ -747,19 +711,16 @@ describe('editor_frame', () => {
doc={{
visualizationType: 'testVis',
title: '',
expression: '',
state: {
datasourceStates: {
testDatasource: {},
testDatasource2: {},
},
visualization: {},
datasourceMetaData: {
filterableIndexPatterns: [],
},
query: { query: '', language: 'lucene' },
filters: [],
},
references: [],
}}
/>
);
@ -802,19 +763,16 @@ describe('editor_frame', () => {
doc={{
visualizationType: 'testVis',
title: '',
expression: '',
state: {
datasourceStates: {
testDatasource: datasource1State,
testDatasource2: datasource2State,
},
visualization: {},
datasourceMetaData: {
filterableIndexPatterns: [],
},
query: { query: '', language: 'lucene' },
filters: [],
},
references: [],
}}
/>
);
@ -842,7 +800,6 @@ describe('editor_frame', () => {
it('should give access to the datasource state in the datasource factory function', async () => {
const datasourceState = {};
const dateRange = { fromDate: 'now-1w', toDate: 'now' };
mockDatasource.initialize.mockResolvedValue(datasourceState);
mockDatasource.getLayers.mockReturnValue(['first']);
@ -850,7 +807,6 @@ describe('editor_frame', () => {
mount(
<EditorFrame
{...getDefaultProps()}
dateRange={dateRange}
visualizationMap={{
testVis: mockVisualization,
}}
@ -865,7 +821,6 @@ describe('editor_frame', () => {
});
expect(mockDatasource.getPublicAPI).toHaveBeenCalledWith({
dateRange,
state: datasourceState,
layerId: 'first',
});
@ -1460,9 +1415,10 @@ describe('editor_frame', () => {
})
);
mockDatasource.getLayers.mockReturnValue(['first']);
mockDatasource.getMetaData.mockReturnValue({
filterableIndexPatterns: [{ id: '1', title: 'resolved' }],
});
mockDatasource.getPersistableState = jest.fn((x) => ({
state: x,
savedObjectReferences: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }],
}));
mockVisualization.initialize.mockReturnValue({ initialState: true });
await act(async () => {
@ -1487,14 +1443,20 @@ describe('editor_frame', () => {
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenNthCalledWith(1, {
filterableIndexPatterns: [{ id: '1', title: 'resolved' }],
filterableIndexPatterns: ['1'],
doc: {
expression: '',
id: undefined,
description: undefined,
references: [
{
id: '1',
name: 'index-pattern-0',
type: 'index-pattern',
},
],
state: {
visualization: null, // Not yet loaded
datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'resolved' }] },
datasourceStates: { testDatasource: undefined },
datasourceStates: { testDatasource: {} },
query: { query: '', language: 'lucene' },
filters: [],
},
@ -1502,18 +1464,23 @@ describe('editor_frame', () => {
type: 'lens',
visualizationType: 'testVis',
},
isSaveable: false,
});
expect(onChange).toHaveBeenLastCalledWith({
filterableIndexPatterns: [{ id: '1', title: 'resolved' }],
filterableIndexPatterns: ['1'],
doc: {
expression: '',
references: [
{
id: '1',
name: 'index-pattern-0',
type: 'index-pattern',
},
],
description: undefined,
id: undefined,
state: {
visualization: { initialState: true }, // Now loaded
datasourceMetaData: {
filterableIndexPatterns: [{ id: '1', title: 'resolved' }],
},
datasourceStates: { testDatasource: undefined },
datasourceStates: { testDatasource: {} },
query: { query: '', language: 'lucene' },
filters: [],
},
@ -1521,6 +1488,7 @@ describe('editor_frame', () => {
type: 'lens',
visualizationType: 'testVis',
},
isSaveable: false,
});
});
@ -1562,11 +1530,10 @@ describe('editor_frame', () => {
expect(onChange).toHaveBeenNthCalledWith(3, {
filterableIndexPatterns: [],
doc: {
expression: expect.stringContaining('vis "expression"'),
id: undefined,
references: [],
state: {
datasourceMetaData: { filterableIndexPatterns: [] },
datasourceStates: { testDatasource: undefined },
datasourceStates: { testDatasource: { datasource: '' } },
visualization: { initialState: true },
query: { query: 'new query', language: 'lucene' },
filters: [],
@ -1575,6 +1542,7 @@ describe('editor_frame', () => {
type: 'lens',
visualizationType: 'testVis',
},
isSaveable: true,
});
});
@ -1583,9 +1551,10 @@ describe('editor_frame', () => {
mockDatasource.initialize.mockResolvedValue({});
mockDatasource.getLayers.mockReturnValue(['first']);
mockDatasource.getMetaData.mockReturnValue({
filterableIndexPatterns: [{ id: '1', title: 'resolved' }],
});
mockDatasource.getPersistableState = jest.fn((x) => ({
state: x,
savedObjectReferences: [{ type: 'index-pattern', id: '1', name: '' }],
}));
mockVisualization.initialize.mockReturnValue({ initialState: true });
await act(async () => {

View file

@ -7,13 +7,7 @@
import React, { useEffect, useReducer } from 'react';
import { CoreSetup, CoreStart } from 'kibana/public';
import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public';
import {
Datasource,
DatasourcePublicAPI,
FramePublicAPI,
Visualization,
DatasourceMetaData,
} from '../../types';
import { Datasource, FramePublicAPI, Visualization } from '../../types';
import { reducer, getInitialState } from './state_management';
import { DataPanelWrapper } from './data_panel_wrapper';
import { ConfigPanelWrapper } from './config_panel';
@ -26,6 +20,7 @@ import { getSavedObjectFormat } from './save';
import { generateId } from '../../id_generator';
import { Filter, Query, SavedQuery } from '../../../../../../src/plugins/data/public';
import { EditorFrameStartPlugins } from '../service';
import { initializeDatasources, createDatasourceLayers } from './state_helpers';
export interface EditorFrameProps {
doc?: Document;
@ -45,8 +40,9 @@ export interface EditorFrameProps {
filters: Filter[];
savedQuery?: SavedQuery;
onChange: (arg: {
filterableIndexPatterns: DatasourceMetaData['filterableIndexPatterns'];
filterableIndexPatterns: string[];
doc: Document;
isSaveable: boolean;
}) => void;
showNoDataPopover: () => void;
}
@ -67,25 +63,19 @@ export function EditorFrame(props: EditorFrameProps) {
// prevents executing dispatch on unmounted component
let isUnmounted = false;
if (!allLoaded) {
Object.entries(props.datasourceMap).forEach(([datasourceId, datasource]) => {
if (
state.datasourceStates[datasourceId] &&
state.datasourceStates[datasourceId].isLoading
) {
datasource
.initialize(state.datasourceStates[datasourceId].state || undefined)
.then((datasourceState) => {
if (!isUnmounted) {
dispatch({
type: 'UPDATE_DATASOURCE_STATE',
updater: datasourceState,
datasourceId,
});
}
})
.catch(onError);
}
});
initializeDatasources(props.datasourceMap, state.datasourceStates, props.doc?.references)
.then((result) => {
if (!isUnmounted) {
Object.entries(result).forEach(([datasourceId, { state: datasourceState }]) => {
dispatch({
type: 'UPDATE_DATASOURCE_STATE',
updater: datasourceState,
datasourceId,
});
});
}
})
.catch(onError);
}
return () => {
isUnmounted = true;
@ -95,22 +85,7 @@ export function EditorFrame(props: EditorFrameProps) {
[allLoaded, onError]
);
const datasourceLayers: Record<string, DatasourcePublicAPI> = {};
Object.keys(props.datasourceMap)
.filter((id) => state.datasourceStates[id] && !state.datasourceStates[id].isLoading)
.forEach((id) => {
const datasourceState = state.datasourceStates[id].state;
const datasource = props.datasourceMap[id];
const layers = datasource.getLayers(datasourceState);
layers.forEach((layer) => {
datasourceLayers[layer] = props.datasourceMap[id].getPublicAPI({
state: datasourceState,
layerId: layer,
dateRange: props.dateRange,
});
});
});
const datasourceLayers = createDatasourceLayers(props.datasourceMap, state.datasourceStates);
const framePublicAPI: FramePublicAPI = {
datasourceLayers,
@ -165,7 +140,18 @@ export function EditorFrame(props: EditorFrameProps) {
if (props.doc) {
dispatch({
type: 'VISUALIZATION_LOADED',
doc: props.doc,
doc: {
...props.doc,
state: {
...props.doc.state,
visualization: props.doc.visualizationType
? props.visualizationMap[props.doc.visualizationType].initialize(
framePublicAPI,
props.doc.state.visualization
)
: props.doc.state.visualization,
},
},
});
} else {
dispatch({
@ -206,36 +192,20 @@ export function EditorFrame(props: EditorFrameProps) {
return;
}
const indexPatterns: DatasourceMetaData['filterableIndexPatterns'] = [];
Object.entries(props.datasourceMap)
.filter(([id, datasource]) => {
const stateWrapper = state.datasourceStates[id];
return (
stateWrapper &&
!stateWrapper.isLoading &&
datasource.getLayers(stateWrapper.state).length > 0
);
props.onChange(
getSavedObjectFormat({
activeDatasources: Object.keys(state.datasourceStates).reduce(
(datasourceMap, datasourceId) => ({
...datasourceMap,
[datasourceId]: props.datasourceMap[datasourceId],
}),
{}
),
visualization: activeVisualization,
state,
framePublicAPI,
})
.forEach(([id, datasource]) => {
indexPatterns.push(
...datasource.getMetaData(state.datasourceStates[id].state).filterableIndexPatterns
);
});
const doc = getSavedObjectFormat({
activeDatasources: Object.keys(state.datasourceStates).reduce(
(datasourceMap, datasourceId) => ({
...datasourceMap,
[datasourceId]: props.datasourceMap[datasourceId],
}),
{}
),
visualization: activeVisualization,
state,
framePublicAPI,
});
props.onChange({ filterableIndexPatterns: indexPatterns, doc });
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[

View file

@ -5,8 +5,7 @@
*/
import { Ast, fromExpression, ExpressionFunctionAST } from '@kbn/interpreter/common';
import { Visualization, Datasource, FramePublicAPI } from '../../types';
import { Filter, TimeRange, Query } from '../../../../../../src/plugins/data/public';
import { Visualization, Datasource, DatasourcePublicAPI } from '../../types';
export function prependDatasourceExpression(
visualizationExpression: Ast | string | null,
@ -58,40 +57,12 @@ export function prependDatasourceExpression(
? fromExpression(visualizationExpression)
: visualizationExpression;
return {
type: 'expression',
chain: [datafetchExpression, ...parsedVisualizationExpression.chain],
};
}
export function prependKibanaContext(
expression: Ast | string,
{
timeRange,
query,
filters,
}: {
timeRange?: TimeRange;
query?: Query;
filters?: Filter[];
}
): Ast {
const parsedExpression = typeof expression === 'string' ? fromExpression(expression) : expression;
return {
type: 'expression',
chain: [
{ type: 'function', function: 'kibana', arguments: {} },
{
type: 'function',
function: 'kibana_context',
arguments: {
timeRange: timeRange ? [JSON.stringify(timeRange)] : [],
query: query ? [JSON.stringify(query)] : [],
filters: [JSON.stringify(filters || [])],
},
},
...parsedExpression.chain,
datafetchExpression,
...parsedVisualizationExpression.chain,
],
};
}
@ -101,8 +72,7 @@ export function buildExpression({
visualizationState,
datasourceMap,
datasourceStates,
framePublicAPI,
removeDateRange,
datasourceLayers,
}: {
visualization: Visualization | null;
visualizationState: unknown;
@ -114,24 +84,12 @@ export function buildExpression({
state: unknown;
}
>;
framePublicAPI: FramePublicAPI;
removeDateRange?: boolean;
datasourceLayers: Record<string, DatasourcePublicAPI>;
}): Ast | null {
if (visualization === null) {
return null;
}
const visualizationExpression = visualization.toExpression(visualizationState, framePublicAPI);
const expressionContext = removeDateRange
? { query: framePublicAPI.query, filters: framePublicAPI.filters }
: {
query: framePublicAPI.query,
timeRange: {
from: framePublicAPI.dateRange.fromDate,
to: framePublicAPI.dateRange.toDate,
},
filters: framePublicAPI.filters,
};
const visualizationExpression = visualization.toExpression(visualizationState, datasourceLayers);
const completeExpression = prependDatasourceExpression(
visualizationExpression,
@ -139,9 +97,5 @@ export function buildExpression({
datasourceStates
);
if (completeExpression) {
return prependKibanaContext(completeExpression, expressionContext);
} else {
return null;
}
return completeExpression;
}

View file

@ -8,14 +8,18 @@ import { getSavedObjectFormat, Props } from './save';
import { createMockDatasource, createMockVisualization } from '../mocks';
import { esFilters, IIndexPattern, IFieldType } from '../../../../../../src/plugins/data/public';
jest.mock('./expression_helpers');
describe('save editor frame state', () => {
const mockVisualization = createMockVisualization();
mockVisualization.getPersistableState.mockImplementation((x) => x);
const mockDatasource = createMockDatasource('a');
const mockIndexPattern = ({ id: 'indexpattern' } as unknown) as IIndexPattern;
const mockField = ({ name: '@timestamp' } as unknown) as IFieldType;
mockDatasource.getPersistableState.mockImplementation((x) => x);
mockDatasource.getPersistableState.mockImplementation((x) => ({
state: x,
savedObjectReferences: [],
}));
const saveArgs: Props = {
activeDatasources: {
indexpattern: mockDatasource,
@ -47,15 +51,17 @@ describe('save editor frame state', () => {
it('transforms from internal state to persisted doc format', async () => {
const datasource = createMockDatasource('a');
datasource.getPersistableState.mockImplementation((state) => ({
stuff: `${state}_datasource_persisted`,
state: {
stuff: `${state}_datasource_persisted`,
},
savedObjectReferences: [],
}));
datasource.toExpression.mockReturnValue('my | expr');
const visualization = createMockVisualization();
visualization.getPersistableState.mockImplementation((state) => ({
things: `${state}_vis_persisted`,
}));
visualization.toExpression.mockReturnValue('vis | expr');
const doc = await getSavedObjectFormat({
const { doc, filterableIndexPatterns, isSaveable } = await getSavedObjectFormat({
...saveArgs,
activeDatasources: {
indexpattern: datasource,
@ -74,27 +80,32 @@ describe('save editor frame state', () => {
visualization,
});
expect(filterableIndexPatterns).toEqual([]);
expect(isSaveable).toEqual(true);
expect(doc).toEqual({
id: undefined,
expression: '',
state: {
datasourceMetaData: {
filterableIndexPatterns: [],
},
datasourceStates: {
indexpattern: {
stuff: '2_datasource_persisted',
},
},
visualization: { things: '4_vis_persisted' },
visualization: '4',
query: { query: '', language: 'lucene' },
filters: [
{
meta: { index: 'indexpattern' },
meta: { indexRefName: 'filter-index-pattern-0' },
exists: { field: '@timestamp' },
},
],
},
references: [
{
id: 'indexpattern',
name: 'filter-index-pattern-0',
type: 'index-pattern',
},
],
title: 'bbb',
type: 'lens',
visualizationType: '3',

View file

@ -5,11 +5,12 @@
*/
import _ from 'lodash';
import { toExpression } from '@kbn/interpreter/target/common';
import { SavedObjectReference } from 'kibana/public';
import { EditorFrameState } from './state_management';
import { Document } from '../../persistence/saved_object_store';
import { buildExpression } from './expression_helpers';
import { Datasource, Visualization, FramePublicAPI } from '../../types';
import { extractFilterReferences } from '../../persistence';
import { buildExpression } from './expression_helpers';
export interface Props {
activeDatasources: Record<string, Datasource>;
@ -23,43 +24,55 @@ export function getSavedObjectFormat({
state,
visualization,
framePublicAPI,
}: Props): Document {
}: Props): {
doc: Document;
filterableIndexPatterns: string[];
isSaveable: boolean;
} {
const datasourceStates: Record<string, unknown> = {};
const references: SavedObjectReference[] = [];
Object.entries(activeDatasources).forEach(([id, datasource]) => {
const { state: persistableState, savedObjectReferences } = datasource.getPersistableState(
state.datasourceStates[id].state
);
datasourceStates[id] = persistableState;
references.push(...savedObjectReferences);
});
const uniqueFilterableIndexPatternIds = _.uniq(
references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)
);
const { persistableFilters, references: filterReferences } = extractFilterReferences(
framePublicAPI.filters
);
references.push(...filterReferences);
const expression = buildExpression({
visualization,
visualizationState: state.visualization.state,
datasourceMap: activeDatasources,
datasourceStates: state.datasourceStates,
framePublicAPI,
removeDateRange: true,
});
const datasourceStates: Record<string, unknown> = {};
Object.entries(activeDatasources).forEach(([id, datasource]) => {
datasourceStates[id] = datasource.getPersistableState(state.datasourceStates[id].state);
});
const filterableIndexPatterns: Array<{ id: string; title: string }> = [];
Object.entries(activeDatasources).forEach(([id, datasource]) => {
filterableIndexPatterns.push(
...datasource.getMetaData(state.datasourceStates[id].state).filterableIndexPatterns
);
datasourceLayers: framePublicAPI.datasourceLayers,
});
return {
id: state.persistedId,
title: state.title,
description: state.description,
type: 'lens',
visualizationType: state.visualization.activeId,
expression: expression ? toExpression(expression) : '',
state: {
datasourceStates,
datasourceMetaData: {
filterableIndexPatterns: _.uniqBy(filterableIndexPatterns, 'id'),
doc: {
id: state.persistedId,
title: state.title,
description: state.description,
type: 'lens',
visualizationType: state.visualization.activeId,
state: {
datasourceStates,
visualization: state.visualization.state,
query: framePublicAPI.query,
filters: persistableFilters,
},
visualization: visualization.getPersistableState(state.visualization.state),
query: framePublicAPI.query,
filters: framePublicAPI.filters,
references,
},
filterableIndexPatterns: uniqueFilterableIndexPatternIds,
isSaveable: expression !== null,
};
}

View file

@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SavedObjectReference } from 'kibana/public';
import { Ast } from '@kbn/interpreter/common';
import { Datasource, DatasourcePublicAPI, Visualization } from '../../types';
import { buildExpression } from './expression_helpers';
import { Document } from '../../persistence/saved_object_store';
export async function initializeDatasources(
datasourceMap: Record<string, Datasource>,
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>,
references?: SavedObjectReference[]
) {
const states: Record<string, { isLoading: boolean; state: unknown }> = {};
await Promise.all(
Object.entries(datasourceMap).map(([datasourceId, datasource]) => {
if (datasourceStates[datasourceId]) {
return datasource
.initialize(datasourceStates[datasourceId].state || undefined, references)
.then((datasourceState) => {
states[datasourceId] = { isLoading: false, state: datasourceState };
});
}
})
);
return states;
}
export function createDatasourceLayers(
datasourceMap: Record<string, Datasource>,
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>
) {
const datasourceLayers: Record<string, DatasourcePublicAPI> = {};
Object.keys(datasourceMap)
.filter((id) => datasourceStates[id] && !datasourceStates[id].isLoading)
.forEach((id) => {
const datasourceState = datasourceStates[id].state;
const datasource = datasourceMap[id];
const layers = datasource.getLayers(datasourceState);
layers.forEach((layer) => {
datasourceLayers[layer] = datasourceMap[id].getPublicAPI({
state: datasourceState,
layerId: layer,
});
});
});
return datasourceLayers;
}
export async function persistedStateToExpression(
datasources: Record<string, Datasource>,
visualizations: Record<string, Visualization>,
doc: Document
): Promise<Ast | null> {
const {
state: { visualization: visualizationState, datasourceStates: persistedDatasourceStates },
visualizationType,
references,
} = doc;
if (!visualizationType) return null;
const visualization = visualizations[visualizationType!];
const datasourceStates = await initializeDatasources(
datasources,
Object.fromEntries(
Object.entries(persistedDatasourceStates).map(([id, state]) => [
id,
{ isLoading: false, state },
])
),
references
);
const datasourceLayers = createDatasourceLayers(datasources, datasourceStates);
return buildExpression({
visualization,
visualizationState,
datasourceMap: datasources,
datasourceStates,
datasourceLayers,
});
}

View file

@ -57,19 +57,16 @@ describe('editor_frame state management', () => {
const initialState = getInitialState({
...props,
doc: {
expression: '',
state: {
datasourceStates: {
testDatasource: { internalState1: '' },
testDatasource2: { internalState2: '' },
},
visualization: {},
datasourceMetaData: {
filterableIndexPatterns: [],
},
query: { query: '', language: 'lucene' },
filters: [],
},
references: [],
title: '',
visualizationType: 'testVis',
},
@ -380,9 +377,7 @@ describe('editor_frame state management', () => {
type: 'VISUALIZATION_LOADED',
doc: {
id: 'b',
expression: '',
state: {
datasourceMetaData: { filterableIndexPatterns: [] },
datasourceStates: { a: { foo: 'c' } },
visualization: { bar: 'd' },
query: { query: '', language: 'lucene' },
@ -392,6 +387,7 @@ describe('editor_frame state management', () => {
description: 'My lens',
type: 'lens',
visualizationType: 'line',
references: [],
},
}
);

View file

@ -107,7 +107,7 @@ export function getSuggestions({
* title and preview expression.
*/
function getVisualizationSuggestions(
visualization: Visualization<unknown, unknown>,
visualization: Visualization<unknown>,
table: TableSuggestion,
visualizationId: string,
datasourceSuggestion: DatasourceSuggestion & { datasourceId: string },

View file

@ -249,7 +249,6 @@ describe('suggestion_panel', () => {
expect(passedExpression).toMatchInlineSnapshot(`
"kibana
| kibana_context timeRange=\\"{\\\\\\"from\\\\\\":\\\\\\"now-7d\\\\\\",\\\\\\"to\\\\\\":\\\\\\"now\\\\\\"}\\" query=\\"{\\\\\\"query\\\\\\":\\\\\\"\\\\\\",\\\\\\"language\\\\\\":\\\\\\"lucene\\\\\\"}\\" filters=\\"[{\\\\\\"meta\\\\\\":{\\\\\\"index\\\\\\":\\\\\\"index1\\\\\\"},\\\\\\"exists\\\\\\":{\\\\\\"field\\\\\\":\\\\\\"myfield\\\\\\"}}]\\"
| lens_merge_tables layerIds=\\"first\\" tables={datasource_expression}
| test
| expression"

View file

@ -21,6 +21,7 @@ import { IconType } from '@elastic/eui/src/components/icon/icon';
import { Ast, toExpression } from '@kbn/interpreter/common';
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import { ExecutionContextSearch } from 'src/plugins/expressions';
import { Action, PreviewState } from './state_management';
import { Datasource, Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types';
import { getSuggestions, switchToSuggestion } from './suggestion_helpers';
@ -28,7 +29,7 @@ import {
ReactExpressionRendererProps,
ReactExpressionRendererType,
} from '../../../../../../src/plugins/expressions/public';
import { prependDatasourceExpression, prependKibanaContext } from './expression_helpers';
import { prependDatasourceExpression } from './expression_helpers';
import { debouncedComponent } from '../../debounced_component';
import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry';
import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public';
@ -112,7 +113,7 @@ const SuggestionPreview = ({
}: {
onSelect: () => void;
preview: {
expression?: Ast;
expression?: Ast | null;
icon: IconType;
title: string;
};
@ -215,12 +216,24 @@ export function SuggestionPanel({
visualizationMap,
]);
const context: ExecutionContextSearch = useMemo(
() => ({
query: frame.query,
timeRange: {
from: frame.dateRange.fromDate,
to: frame.dateRange.toDate,
},
filters: frame.filters,
}),
[frame.query, frame.dateRange.fromDate, frame.dateRange.toDate, frame.filters]
);
const AutoRefreshExpressionRenderer = useMemo(() => {
const autoRefreshFetch$ = plugins.data.query.timefilter.timefilter.getAutoRefreshFetch$();
return (props: ReactExpressionRendererProps) => (
<ExpressionRendererComponent {...props} reload$={autoRefreshFetch$} />
<ExpressionRendererComponent {...props} searchContext={context} reload$={autoRefreshFetch$} />
);
}, [plugins.data.query.timefilter.timefilter]);
}, [plugins.data.query.timefilter.timefilter, context]);
const [lastSelectedSuggestion, setLastSelectedSuggestion] = useState<number>(-1);
@ -252,15 +265,6 @@ export function SuggestionPanel({
}
}
const expressionContext = {
query: frame.query,
filters: frame.filters,
timeRange: {
from: frame.dateRange.fromDate,
to: frame.dateRange.toDate,
},
};
return (
<div className="lnsSuggestionPanel">
<EuiFlexGroup alignItems="center">
@ -305,9 +309,7 @@ export function SuggestionPanel({
{currentVisualizationId && (
<SuggestionPreview
preview={{
expression: currentStateExpression
? prependKibanaContext(currentStateExpression, expressionContext)
: undefined,
expression: currentStateExpression,
icon:
visualizationMap[currentVisualizationId].getDescription(currentVisualizationState)
.icon || 'empty',
@ -325,9 +327,7 @@ export function SuggestionPanel({
return (
<SuggestionPreview
preview={{
expression: suggestion.previewExpression
? prependKibanaContext(suggestion.previewExpression, expressionContext)
: undefined,
expression: suggestion.previewExpression,
icon: suggestion.previewIcon,
title: suggestion.title,
}}
@ -391,7 +391,6 @@ function getPreviewExpression(
if (updatedLayerApis[layerId]) {
updatedLayerApis[layerId] = datasource.getPublicAPI({
layerId,
dateRange: frame.dateRange,
state: datasourceState,
});
}
@ -400,7 +399,7 @@ function getPreviewExpression(
return visualization.toPreviewExpression(
visualizableState.visualizationState,
suggestionFrameApi
suggestionFrameApi.datasourceLayers
);
}

View file

@ -260,7 +260,7 @@ export function ChartSwitch(props: Props) {
function getTopSuggestion(
props: Props,
visualizationId: string,
newVisualization: Visualization<unknown, unknown>,
newVisualization: Visualization<unknown>,
subVisualizationId?: string
): Suggestion | undefined {
const unfilteredSuggestions = getSuggestions({

View file

@ -172,21 +172,6 @@ describe('workspace_panel', () => {
"function": "kibana",
"type": "function",
},
Object {
"arguments": Object {
"filters": Array [
"[]",
],
"query": Array [
"{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}",
],
"timeRange": Array [
"{\\"from\\":\\"now-7d\\",\\"to\\":\\"now\\"}",
],
},
"function": "kibana_context",
"type": "function",
},
Object {
"arguments": Object {
"layerIds": Array [
@ -305,10 +290,10 @@ describe('workspace_panel', () => {
);
expect(
(instance.find(expressionRendererMock).prop('expression') as Ast).chain[2].arguments.layerIds
(instance.find(expressionRendererMock).prop('expression') as Ast).chain[1].arguments.layerIds
).toEqual(['first', 'second', 'third']);
expect(
(instance.find(expressionRendererMock).prop('expression') as Ast).chain[2].arguments.tables
(instance.find(expressionRendererMock).prop('expression') as Ast).chain[1].arguments.tables
).toMatchInlineSnapshot(`
Array [
Object {

View file

@ -18,6 +18,7 @@ import {
EuiLink,
} from '@elastic/eui';
import { CoreStart, CoreSetup } from 'kibana/public';
import { ExecutionContextSearch } from 'src/plugins/expressions';
import {
ExpressionRendererEvent,
ReactExpressionRendererType,
@ -129,7 +130,7 @@ export function InnerWorkspacePanel({
visualizationState,
datasourceMap,
datasourceStates,
framePublicAPI,
datasourceLayers: framePublicAPI.datasourceLayers,
});
} catch (e) {
// Most likely an error in the expression provided by a datasource or visualization
@ -173,6 +174,23 @@ export function InnerWorkspacePanel({
[plugins.data.query.timefilter.timefilter]
);
const context: ExecutionContextSearch = useMemo(
() => ({
query: framePublicAPI.query,
timeRange: {
from: framePublicAPI.dateRange.fromDate,
to: framePublicAPI.dateRange.toDate,
},
filters: framePublicAPI.filters,
}),
[
framePublicAPI.query,
framePublicAPI.dateRange.fromDate,
framePublicAPI.dateRange.toDate,
framePublicAPI.filters,
]
);
useEffect(() => {
// reset expression error if component attempts to run it again
if (expression && localState.expressionBuildError) {
@ -264,6 +282,7 @@ export function InnerWorkspacePanel({
className="lnsExpressionRenderer__component"
padding="m"
expression={expression!}
searchContext={context}
reload$={autoRefreshFetch$}
onEvent={onEvent}
renderError={(errorMessage?: string | null) => {

View file

@ -18,16 +18,13 @@ jest.mock('../../../../../../src/plugins/inspector/public/', () => ({
}));
const savedVis: Document = {
expression: 'my | expression',
state: {
visualization: {},
datasourceStates: {},
datasourceMetaData: {
filterableIndexPatterns: [],
},
query: { query: '', language: 'lucene' },
filters: [],
},
references: [],
title: 'My title',
visualizationType: '',
};
@ -59,13 +56,14 @@ describe('embeddable', () => {
editUrl: '',
editable: true,
savedVis,
expression: 'my | expression',
},
{ id: '123' }
);
embeddable.render(mountpoint);
expect(expressionRenderer).toHaveBeenCalledTimes(1);
expect(expressionRenderer.mock.calls[0][0]!.expression).toEqual(savedVis.expression);
expect(expressionRenderer.mock.calls[0][0]!.expression).toEqual('my | expression');
});
it('should re-render if new input is pushed', () => {
@ -82,6 +80,7 @@ describe('embeddable', () => {
editUrl: '',
editable: true,
savedVis,
expression: 'my | expression',
},
{ id: '123' }
);
@ -110,6 +109,7 @@ describe('embeddable', () => {
editUrl: '',
editable: true,
savedVis,
expression: 'my | expression',
},
{ id: '123', timeRange, query, filters }
);
@ -117,11 +117,52 @@ describe('embeddable', () => {
expect(expressionRenderer.mock.calls[0][0].searchContext).toEqual({
timeRange,
query,
query: [query, savedVis.state.query],
filters,
});
});
it('should merge external context with query and filters of the saved object', () => {
const timeRange: TimeRange = { from: 'now-15d', to: 'now' };
const query: Query = { language: 'kquery', query: 'external filter' };
const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }];
const embeddable = new Embeddable(
dataPluginMock.createSetupContract().query.timefilter.timefilter,
expressionRenderer,
getTrigger,
{
editPath: '',
editUrl: '',
editable: true,
savedVis: {
...savedVis,
state: {
...savedVis.state,
query: { language: 'kquery', query: 'saved filter' },
filters: [
{ meta: { alias: 'test', negate: false, disabled: false, indexRefName: 'filter-0' } },
],
},
references: [{ type: 'index-pattern', name: 'filter-0', id: 'my-index-pattern-id' }],
},
expression: 'my | expression',
},
{ id: '123', timeRange, query, filters }
);
embeddable.render(mountpoint);
expect(expressionRenderer.mock.calls[0][0].searchContext).toEqual({
timeRange,
query: [query, { language: 'kquery', query: 'saved filter' }],
filters: [
filters[0],
// actual index pattern id gets injected
{ meta: { alias: 'test', negate: false, disabled: false, index: 'my-index-pattern-id' } },
],
});
});
it('should execute trigger on event from expression renderer', () => {
const embeddable = new Embeddable(
dataPluginMock.createSetupContract().query.timefilter.timefilter,
@ -132,6 +173,7 @@ describe('embeddable', () => {
editUrl: '',
editable: true,
savedVis,
expression: 'my | expression',
},
{ id: '123' }
);
@ -162,6 +204,7 @@ describe('embeddable', () => {
editUrl: '',
editable: true,
savedVis,
expression: 'my | expression',
},
{ id: '123', timeRange, query, filters }
);
@ -195,6 +238,7 @@ describe('embeddable', () => {
editUrl: '',
editable: true,
savedVis,
expression: 'my | expression',
},
{ id: '123', timeRange, query, filters }
);

View file

@ -14,6 +14,7 @@ import {
TimefilterContract,
TimeRange,
} from 'src/plugins/data/public';
import { ExecutionContextSearch } from 'src/plugins/expressions';
import { Subscription } from 'rxjs';
import {
@ -28,12 +29,13 @@ import {
EmbeddableOutput,
IContainer,
} from '../../../../../../src/plugins/embeddable/public';
import { DOC_TYPE, Document } from '../../persistence';
import { DOC_TYPE, Document, injectFilterReferences } from '../../persistence';
import { ExpressionWrapper } from './expression_wrapper';
import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public';
import { isLensBrushEvent, isLensFilterEvent } from '../../types';
export interface LensEmbeddableConfiguration {
expression: string | null;
savedVis: Document;
editUrl: string;
editPath: string;
@ -56,12 +58,13 @@ export class Embeddable extends AbstractEmbeddable<LensEmbeddableInput, LensEmbe
private expressionRenderer: ReactExpressionRendererType;
private getTrigger: UiActionsStart['getTrigger'] | undefined;
private expression: string | null;
private savedVis: Document;
private domNode: HTMLElement | Element | undefined;
private subscription: Subscription;
private autoRefreshFetchSubscription: Subscription;
private currentContext: {
private externalSearchContext: {
timeRange?: TimeRange;
query?: Query;
filters?: Filter[];
@ -72,7 +75,14 @@ export class Embeddable extends AbstractEmbeddable<LensEmbeddableInput, LensEmbe
timefilter: TimefilterContract,
expressionRenderer: ReactExpressionRendererType,
getTrigger: UiActionsStart['getTrigger'] | undefined,
{ savedVis, editPath, editUrl, editable, indexPatterns }: LensEmbeddableConfiguration,
{
expression,
savedVis,
editPath,
editUrl,
editable,
indexPatterns,
}: LensEmbeddableConfiguration,
initialInput: LensEmbeddableInput,
parent?: IContainer
) {
@ -95,6 +105,7 @@ export class Embeddable extends AbstractEmbeddable<LensEmbeddableInput, LensEmbe
this.getTrigger = getTrigger;
this.expressionRenderer = expressionRenderer;
this.expression = expression;
this.savedVis = savedVis;
this.subscription = this.getInput$().subscribe((input) => this.onContainerStateChanged(input));
this.onContainerStateChanged(initialInput);
@ -122,14 +133,14 @@ export class Embeddable extends AbstractEmbeddable<LensEmbeddableInput, LensEmbe
? containerState.filters.filter((filter) => !filter.meta.disabled)
: undefined;
if (
!_.isEqual(containerState.timeRange, this.currentContext.timeRange) ||
!_.isEqual(containerState.query, this.currentContext.query) ||
!_.isEqual(cleanedFilters, this.currentContext.filters)
!_.isEqual(containerState.timeRange, this.externalSearchContext.timeRange) ||
!_.isEqual(containerState.query, this.externalSearchContext.query) ||
!_.isEqual(cleanedFilters, this.externalSearchContext.filters)
) {
this.currentContext = {
this.externalSearchContext = {
timeRange: containerState.timeRange,
query: containerState.query,
lastReloadRequestTime: this.currentContext.lastReloadRequestTime,
lastReloadRequestTime: this.externalSearchContext.lastReloadRequestTime,
filters: cleanedFilters,
};
@ -149,14 +160,37 @@ export class Embeddable extends AbstractEmbeddable<LensEmbeddableInput, LensEmbe
render(
<ExpressionWrapper
ExpressionRenderer={this.expressionRenderer}
expression={this.savedVis.expression}
context={this.currentContext}
expression={this.expression}
searchContext={this.getMergedSearchContext()}
handleEvent={this.handleEvent}
/>,
domNode
);
}
/**
* Combines the embeddable context with the saved object context, and replaces
* any references to index patterns
*/
private getMergedSearchContext(): ExecutionContextSearch {
const output: ExecutionContextSearch = {
timeRange: this.externalSearchContext.timeRange,
};
if (this.externalSearchContext.query) {
output.query = [this.externalSearchContext.query, this.savedVis.state.query];
} else {
output.query = [this.savedVis.state.query];
}
if (this.externalSearchContext.filters?.length) {
output.filters = [...this.externalSearchContext.filters, ...this.savedVis.state.filters];
} else {
output.filters = [...this.savedVis.state.filters];
}
output.filters = injectFilterReferences(output.filters, this.savedVis.references);
return output;
}
handleEvent = (event: ExpressionRendererEvent) => {
if (!this.getTrigger || this.input.disableTriggers) {
return;
@ -188,9 +222,9 @@ export class Embeddable extends AbstractEmbeddable<LensEmbeddableInput, LensEmbe
reload() {
const currentTime = Date.now();
if (this.currentContext.lastReloadRequestTime !== currentTime) {
this.currentContext = {
...this.currentContext,
if (this.externalSearchContext.lastReloadRequestTime !== currentTime) {
this.externalSearchContext = {
...this.externalSearchContext,
lastReloadRequestTime: currentTime,
};

View file

@ -7,6 +7,7 @@
import { Capabilities, HttpSetup, SavedObjectsClientContract } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { RecursiveReadonly } from '@kbn/utility-types';
import { toExpression, Ast } from '@kbn/interpreter/target/common';
import {
IndexPatternsContract,
IndexPattern,
@ -23,6 +24,7 @@ import { Embeddable } from './embeddable';
import { SavedObjectIndexStore, DOC_TYPE } from '../../persistence';
import { getEditPath } from '../../../common';
import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public';
import { Document } from '../../persistence/saved_object_store';
interface StartServices {
timefilter: TimefilterContract;
@ -32,6 +34,7 @@ interface StartServices {
expressionRenderer: ReactExpressionRendererType;
indexPatternService: IndexPatternsContract;
uiActions?: UiActionsStart;
documentToExpression: (doc: Document) => Promise<Ast | null>;
}
export class EmbeddableFactory implements EmbeddableFactoryDefinition {
@ -72,13 +75,15 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
indexPatternService,
timefilter,
expressionRenderer,
documentToExpression,
uiActions,
} = await this.getStartServices();
const store = new SavedObjectIndexStore(savedObjectsClient);
const savedVis = await store.load(savedObjectId);
const promises = savedVis.state.datasourceMetaData.filterableIndexPatterns.map(
async ({ id }) => {
const promises = savedVis.references
.filter(({ type }) => type === 'index-pattern')
.map(async ({ id }) => {
try {
return await indexPatternService.get(id);
} catch (error) {
@ -87,14 +92,15 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
// to show.
return null;
}
}
);
});
const indexPatterns = (
await Promise.all(promises)
).filter((indexPattern: IndexPattern | null): indexPattern is IndexPattern =>
Boolean(indexPattern)
);
const expression = await documentToExpression(savedVis);
return new Embeddable(
timefilter,
expressionRenderer,
@ -105,6 +111,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
editUrl: coreHttp.basePath.prepend(`/app/lens${getEditPath(savedObjectId)}`),
editable: await this.isEditable(),
indexPatterns,
expression: expression ? toExpression(expression) : null,
},
input,
parent

View file

@ -8,28 +8,23 @@ import React from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon } from '@elastic/eui';
import { TimeRange, Filter, Query } from 'src/plugins/data/public';
import {
ExpressionRendererEvent,
ReactExpressionRendererType,
} from 'src/plugins/expressions/public';
import { ExecutionContextSearch } from 'src/plugins/expressions';
export interface ExpressionWrapperProps {
ExpressionRenderer: ReactExpressionRendererType;
expression: string | null;
context: {
timeRange?: TimeRange;
query?: Query;
filters?: Filter[];
lastReloadRequestTime?: number;
};
searchContext: ExecutionContextSearch;
handleEvent: (event: ExpressionRendererEvent) => void;
}
export function ExpressionWrapper({
ExpressionRenderer: ExpressionRendererComponent,
expression,
context,
searchContext,
handleEvent,
}: ExpressionWrapperProps) {
return (
@ -54,7 +49,7 @@ export function ExpressionWrapper({
className="lnsExpressionRenderer__component"
padding="m"
expression={expression}
searchContext={{ ...context }}
searchContext={searchContext}
renderError={(error) => <div data-test-subj="expression-renderer-error">{error}</div>}
onEvent={handleEvent}
/>

View file

@ -31,7 +31,6 @@ export function createMockVisualization(): jest.Mocked<Visualization> {
getVisualizationTypeId: jest.fn((_state) => 'empty'),
getDescription: jest.fn((_state) => ({ label: '' })),
switchVisualizationType: jest.fn((_, x) => x),
getPersistableState: jest.fn((_state) => _state),
getSuggestions: jest.fn((_options) => []),
initialize: jest.fn((_frame, _state?) => ({})),
getConfiguration: jest.fn((props) => ({
@ -71,7 +70,7 @@ export function createMockDatasource(id: string): DatasourceMock {
clearLayer: jest.fn((state, _layerId) => state),
getDatasourceSuggestionsForField: jest.fn((_state, _item) => []),
getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []),
getPersistableState: jest.fn(),
getPersistableState: jest.fn((x) => ({ state: x, savedObjectReferences: [] })),
getPublicAPI: jest.fn().mockReturnValue(publicAPIMock),
initialize: jest.fn((_state?) => Promise.resolve()),
renderDataPanel: jest.fn(),
@ -81,7 +80,6 @@ export function createMockDatasource(id: string): DatasourceMock {
removeLayer: jest.fn((_state, _layerId) => {}),
removeColumn: jest.fn((props) => {}),
getLayers: jest.fn((_state) => []),
getMetaData: jest.fn((_state) => ({ filterableIndexPatterns: [] })),
renderDimensionTrigger: jest.fn(),
renderDimensionEditor: jest.fn(),

View file

@ -21,12 +21,14 @@ import {
EditorFrameInstance,
EditorFrameStart,
} from '../types';
import { Document } from '../persistence/saved_object_store';
import { EditorFrame } from './editor_frame';
import { mergeTables } from './merge_tables';
import { formatColumn } from './format_column';
import { EmbeddableFactory } from './embeddable/embeddable_factory';
import { getActiveDatasourceIdFromDoc } from './editor_frame/state_management';
import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
import { persistedStateToExpression } from './editor_frame/state_helpers';
export interface EditorFrameSetupPlugins {
data: DataPublicPluginSetup;
@ -59,6 +61,21 @@ export class EditorFrameService {
private readonly datasources: Array<Datasource | Promise<Datasource>> = [];
private readonly visualizations: Array<Visualization | Promise<Visualization>> = [];
/**
* This method takes a Lens saved object as returned from the persistence helper,
* initializes datsources and visualization and creates the current expression.
* This is an asynchronous process and should only be triggered once for a saved object.
* @param doc parsed Lens saved object
*/
private async documentToExpression(doc: Document) {
const [resolvedDatasources, resolvedVisualizations] = await Promise.all([
collectAsyncDefinitions(this.datasources),
collectAsyncDefinitions(this.visualizations),
]);
return await persistedStateToExpression(resolvedDatasources, resolvedVisualizations, doc);
}
public setup(
core: CoreSetup<EditorFrameStartPlugins>,
plugins: EditorFrameSetupPlugins
@ -74,6 +91,7 @@ export class EditorFrameService {
coreHttp: coreStart.http,
timefilter: deps.data.query.timefilter.timefilter,
expressionRenderer: deps.expressions.ReactExpressionRenderer,
documentToExpression: this.documentToExpression.bind(this),
indexPatternService: deps.data.indexPatterns,
uiActions: deps.uiActions,
};
@ -88,7 +106,7 @@ export class EditorFrameService {
this.datasources.push(datasource as Datasource<unknown, unknown>);
},
registerVisualization: (visualization) => {
this.visualizations.push(visualization as Visualization<unknown, unknown>);
this.visualizations.push(visualization as Visualization<unknown>);
},
};
}

View file

@ -23,3 +23,7 @@ export function loadInitialState() {
};
return result;
}
const originalLoader = jest.requireActual('../loader');
export const extractReferences = originalLoader.extractReferences;

View file

@ -128,12 +128,15 @@ const expectedIndexPatterns = {
},
};
function stateFromPersistedState(
persistedState: IndexPatternPersistedState
): IndexPatternPrivateState {
type IndexPatternBaseState = Omit<
IndexPatternPrivateState,
'indexPatternRefs' | 'indexPatterns' | 'existingFields' | 'isFirstExistenceFetch'
>;
function enrichBaseState(baseState: IndexPatternBaseState): IndexPatternPrivateState {
return {
currentIndexPatternId: persistedState.currentIndexPatternId,
layers: persistedState.layers,
currentIndexPatternId: baseState.currentIndexPatternId,
layers: baseState.layers,
indexPatterns: expectedIndexPatterns,
indexPatternRefs: [],
existingFields: {},
@ -142,7 +145,10 @@ function stateFromPersistedState(
}
describe('IndexPattern Data Source', () => {
let persistedState: IndexPatternPersistedState;
let baseState: Omit<
IndexPatternPrivateState,
'indexPatternRefs' | 'indexPatterns' | 'existingFields' | 'isFirstExistenceFetch'
>;
let indexPatternDatasource: Datasource<IndexPatternPrivateState, IndexPatternPersistedState>;
beforeEach(() => {
@ -153,7 +159,7 @@ describe('IndexPattern Data Source', () => {
charts: chartPluginMock.createSetupContract(),
});
persistedState = {
baseState = {
currentIndexPatternId: '1',
layers: {
first: {
@ -224,9 +230,37 @@ describe('IndexPattern Data Source', () => {
describe('#getPersistedState', () => {
it('should persist from saved state', async () => {
const state = stateFromPersistedState(persistedState);
const state = enrichBaseState(baseState);
expect(indexPatternDatasource.getPersistableState(state)).toEqual(persistedState);
expect(indexPatternDatasource.getPersistableState(state)).toEqual({
state: {
layers: {
first: {
columnOrder: ['col1'],
columns: {
col1: {
label: 'My Op',
dataType: 'string',
isBucketed: true,
// Private
operationType: 'terms',
sourceField: 'op',
params: {
size: 5,
orderBy: { type: 'alphabetical' },
orderDirection: 'asc',
},
},
},
},
},
},
savedObjectReferences: [
{ name: 'indexpattern-datasource-current-indexpattern', type: 'index-pattern', id: '1' },
{ name: 'indexpattern-datasource-layer-first', type: 'index-pattern', id: '1' },
],
});
});
});
@ -237,7 +271,7 @@ describe('IndexPattern Data Source', () => {
});
it('should generate an expression for an aggregated query', async () => {
const queryPersistedState: IndexPatternPersistedState = {
const queryBaseState: IndexPatternBaseState = {
currentIndexPatternId: '1',
layers: {
first: {
@ -266,7 +300,7 @@ describe('IndexPattern Data Source', () => {
},
};
const state = stateFromPersistedState(queryPersistedState);
const state = enrichBaseState(queryBaseState);
expect(indexPatternDatasource.toExpression(state, 'first')).toMatchInlineSnapshot(`
Object {
@ -311,7 +345,7 @@ describe('IndexPattern Data Source', () => {
});
it('should put all time fields used in date_histograms to the esaggs timeFields parameter', async () => {
const queryPersistedState: IndexPatternPersistedState = {
const queryBaseState: IndexPatternBaseState = {
currentIndexPatternId: '1',
layers: {
first: {
@ -350,14 +384,14 @@ describe('IndexPattern Data Source', () => {
},
};
const state = stateFromPersistedState(queryPersistedState);
const state = enrichBaseState(queryBaseState);
const ast = indexPatternDatasource.toExpression(state, 'first') as Ast;
expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']);
});
it('should not put date fields used outside date_histograms to the esaggs timeFields parameter', async () => {
const queryPersistedState: IndexPatternPersistedState = {
const queryBaseState: IndexPatternBaseState = {
currentIndexPatternId: '1',
layers: {
first: {
@ -386,7 +420,7 @@ describe('IndexPattern Data Source', () => {
},
};
const state = stateFromPersistedState(queryPersistedState);
const state = enrichBaseState(queryBaseState);
const ast = indexPatternDatasource.toExpression(state, 'first') as Ast;
expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']);
@ -489,55 +523,14 @@ describe('IndexPattern Data Source', () => {
});
});
describe('#getMetadata', () => {
it('should return the title of the index patterns', () => {
expect(
indexPatternDatasource.getMetaData({
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: expectedIndexPatterns,
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
second: {
indexPatternId: '2',
columnOrder: [],
columns: {},
},
},
currentIndexPatternId: '1',
})
).toEqual({
filterableIndexPatterns: [
{
id: '1',
title: 'my-fake-index-pattern',
},
{
id: '2',
title: 'my-fake-restricted-pattern',
},
],
});
});
});
describe('#getPublicAPI', () => {
let publicAPI: DatasourcePublicAPI;
beforeEach(async () => {
const initialState = stateFromPersistedState(persistedState);
const initialState = enrichBaseState(baseState);
publicAPI = indexPatternDatasource.getPublicAPI({
state: initialState,
layerId: 'first',
dateRange: {
fromDate: 'now-30d',
toDate: 'now',
},
});
});

View file

@ -8,7 +8,7 @@ import _ from 'lodash';
import React from 'react';
import { render } from 'react-dom';
import { I18nProvider } from '@kbn/i18n/react';
import { CoreStart } from 'kibana/public';
import { CoreStart, SavedObjectReference } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import {
@ -19,7 +19,12 @@ import {
DatasourceLayerPanelProps,
PublicAPIProps,
} from '../types';
import { loadInitialState, changeIndexPattern, changeLayerIndexPattern } from './loader';
import {
loadInitialState,
changeIndexPattern,
changeLayerIndexPattern,
extractReferences,
} from './loader';
import { toExpression } from './to_expression';
import {
IndexPatternDimensionTrigger,
@ -125,9 +130,13 @@ export function getIndexPatternDatasource({
const indexPatternDatasource: Datasource<IndexPatternPrivateState, IndexPatternPersistedState> = {
id: 'indexpattern',
async initialize(state?: IndexPatternPersistedState) {
async initialize(
persistedState?: IndexPatternPersistedState,
references?: SavedObjectReference[]
) {
return loadInitialState({
state,
persistedState,
references,
savedObjectsClient: await savedObjectsClient,
defaultIndexPatternId: core.uiSettings.get('defaultIndex'),
storage,
@ -135,8 +144,8 @@ export function getIndexPatternDatasource({
});
},
getPersistableState({ currentIndexPatternId, layers }: IndexPatternPrivateState) {
return { currentIndexPatternId, layers };
getPersistableState(state: IndexPatternPrivateState) {
return extractReferences(state);
},
insertLayer(state: IndexPatternPrivateState, newLayerId: string) {
@ -183,19 +192,6 @@ export function getIndexPatternDatasource({
toExpression,
getMetaData(state: IndexPatternPrivateState) {
return {
filterableIndexPatterns: _.uniq(
Object.values(state.layers)
.map((layer) => layer.indexPatternId)
.map((indexPatternId) => ({
id: indexPatternId,
title: state.indexPatterns[indexPatternId].title,
}))
),
};
},
renderDataPanel(
domElement: Element,
props: DatasourceDataPanelProps<IndexPatternPrivateState>

View file

@ -12,6 +12,8 @@ import {
changeIndexPattern,
changeLayerIndexPattern,
syncExistingFields,
extractReferences,
injectReferences,
} from './loader';
import { IndexPatternsContract } from '../../../../../src/plugins/data/public';
import {
@ -378,10 +380,8 @@ describe('loader', () => {
it('should initialize from saved state', async () => {
const savedState: IndexPatternPersistedState = {
currentIndexPatternId: '2',
layers: {
layerb: {
indexPatternId: '2',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
@ -407,7 +407,12 @@ describe('loader', () => {
};
const storage = createMockStorage({ indexPatternId: '1' });
const state = await loadInitialState({
state: savedState,
persistedState: savedState,
references: [
{ name: 'indexpattern-datasource-current-indexpattern', id: '2', type: 'index-pattern' },
{ name: 'indexpattern-datasource-layer-layerb', id: '2', type: 'index-pattern' },
{ name: 'another-reference', id: 'c', type: 'index-pattern' },
],
savedObjectsClient: mockClient(),
indexPatternsService: mockIndexPatternsService(),
storage,
@ -422,7 +427,7 @@ describe('loader', () => {
indexPatterns: {
'2': sampleIndexPatterns['2'],
},
layers: savedState.layers,
layers: { layerb: { ...savedState.layers.layerb, indexPatternId: '2' } },
});
expect(storage.set).toHaveBeenCalledWith('lens-settings', {
@ -431,6 +436,79 @@ describe('loader', () => {
});
});
describe('saved object references', () => {
const state: IndexPatternPrivateState = {
currentIndexPatternId: 'b',
indexPatternRefs: [],
indexPatterns: {},
existingFields: {},
layers: {
a: {
indexPatternId: 'id-index-pattern-a',
columnOrder: ['col1'],
columns: {
col1: {
dataType: 'number',
isBucketed: false,
label: '',
operationType: 'avg',
sourceField: 'myfield',
},
},
},
b: {
indexPatternId: 'id-index-pattern-b',
columnOrder: ['col2'],
columns: {
col2: {
dataType: 'number',
isBucketed: false,
label: '',
operationType: 'avg',
sourceField: 'myfield2',
},
},
},
},
isFirstExistenceFetch: false,
};
it('should create a reference for each layer and for current index pattern', () => {
const { savedObjectReferences } = extractReferences(state);
expect(savedObjectReferences).toMatchInlineSnapshot(`
Array [
Object {
"id": "b",
"name": "indexpattern-datasource-current-indexpattern",
"type": "index-pattern",
},
Object {
"id": "id-index-pattern-a",
"name": "indexpattern-datasource-layer-a",
"type": "index-pattern",
},
Object {
"id": "id-index-pattern-b",
"name": "indexpattern-datasource-layer-b",
"type": "index-pattern",
},
]
`);
});
it('should restore layers', () => {
const { savedObjectReferences, state: persistedState } = extractReferences(state);
expect(injectReferences(persistedState, savedObjectReferences).layers).toEqual(state.layers);
});
it('should restore current index pattern', () => {
const { savedObjectReferences, state: persistedState } = extractReferences(state);
expect(injectReferences(persistedState, savedObjectReferences).currentIndexPatternId).toEqual(
state.currentIndexPatternId
);
});
});
describe('changeIndexPattern', () => {
it('loads the index pattern and then sets it as current', async () => {
const setState = jest.fn();

View file

@ -6,7 +6,7 @@
import _ from 'lodash';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { SavedObjectsClientContract, HttpSetup } from 'kibana/public';
import { SavedObjectsClientContract, HttpSetup, SavedObjectReference } from 'kibana/public';
import { StateSetter } from '../types';
import {
IndexPattern,
@ -14,6 +14,7 @@ import {
IndexPatternPersistedState,
IndexPatternPrivateState,
IndexPatternField,
IndexPatternLayer,
} from './types';
import { updateLayerIndexPattern } from './state_helpers';
import { DateRange, ExistingFields } from '../../common/types';
@ -115,14 +116,58 @@ const setLastUsedIndexPatternId = (storage: IStorageWrapper, value: string) => {
writeToStorage(storage, 'indexPatternId', value);
};
const CURRENT_PATTERN_REFERENCE_NAME = 'indexpattern-datasource-current-indexpattern';
function getLayerReferenceName(layerId: string) {
return `indexpattern-datasource-layer-${layerId}`;
}
export function extractReferences({ currentIndexPatternId, layers }: IndexPatternPrivateState) {
const savedObjectReferences: SavedObjectReference[] = [];
savedObjectReferences.push({
type: 'index-pattern',
id: currentIndexPatternId,
name: CURRENT_PATTERN_REFERENCE_NAME,
});
const persistableLayers: Record<string, Omit<IndexPatternLayer, 'indexPatternId'>> = {};
Object.entries(layers).forEach(([layerId, { indexPatternId, ...persistableLayer }]) => {
savedObjectReferences.push({
type: 'index-pattern',
id: indexPatternId,
name: getLayerReferenceName(layerId),
});
persistableLayers[layerId] = persistableLayer;
});
return { savedObjectReferences, state: { layers: persistableLayers } };
}
export function injectReferences(
state: IndexPatternPersistedState,
references: SavedObjectReference[]
) {
const layers: Record<string, IndexPatternLayer> = {};
Object.entries(state.layers).forEach(([layerId, persistedLayer]) => {
layers[layerId] = {
...persistedLayer,
indexPatternId: references.find(({ name }) => name === getLayerReferenceName(layerId))!.id,
};
});
return {
currentIndexPatternId: references.find(({ name }) => name === CURRENT_PATTERN_REFERENCE_NAME)!
.id,
layers,
};
}
export async function loadInitialState({
state,
persistedState,
references,
savedObjectsClient,
defaultIndexPatternId,
storage,
indexPatternsService,
}: {
state?: IndexPatternPersistedState;
persistedState?: IndexPatternPersistedState;
references?: SavedObjectReference[];
savedObjectsClient: SavedObjectsClient;
defaultIndexPatternId?: string;
storage: IStorageWrapper;
@ -131,6 +176,9 @@ export async function loadInitialState({
const indexPatternRefs = await loadIndexPatternRefs(savedObjectsClient);
const lastUsedIndexPatternId = getLastUsedIndexPatternId(storage, indexPatternRefs);
const state =
persistedState && references ? injectReferences(persistedState, references) : undefined;
const requiredPatterns = _.uniq(
state
? Object.values(state.layers)

View file

@ -40,11 +40,12 @@ export interface IndexPatternLayer {
}
export interface IndexPatternPersistedState {
currentIndexPatternId: string;
layers: Record<string, IndexPatternLayer>;
layers: Record<string, Omit<IndexPatternLayer, 'indexPatternId'>>;
}
export type IndexPatternPrivateState = IndexPatternPersistedState & {
export interface IndexPatternPrivateState {
currentIndexPatternId: string;
layers: Record<string, IndexPatternLayer>;
indexPatternRefs: IndexPatternRef[];
indexPatterns: Record<string, IndexPattern>;
@ -54,7 +55,7 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & {
existingFields: Record<string, Record<string, boolean>>;
isFirstExistenceFetch: boolean;
existenceFetchFailed?: boolean;
};
}
export interface IndexPatternRef {
id: string;

View file

@ -66,12 +66,6 @@ describe('metric_visualization', () => {
});
});
describe('#getPersistableState', () => {
it('persists the state as given', () => {
expect(metricVisualization.getPersistableState(exampleState())).toEqual(exampleState());
});
});
describe('#getConfiguration', () => {
it('can add a metric when there is no accessor', () => {
expect(
@ -168,7 +162,8 @@ describe('metric_visualization', () => {
datasourceLayers: { l1: datasource },
};
expect(metricVisualization.toExpression(exampleState(), frame)).toMatchInlineSnapshot(`
expect(metricVisualization.toExpression(exampleState(), frame.datasourceLayers))
.toMatchInlineSnapshot(`
Object {
"chain": Array [
Object {

View file

@ -7,20 +7,20 @@
import { i18n } from '@kbn/i18n';
import { Ast } from '@kbn/interpreter/target/common';
import { getSuggestions } from './metric_suggestions';
import { Visualization, FramePublicAPI, OperationMetadata } from '../types';
import { State, PersistableState } from './types';
import { Visualization, OperationMetadata, DatasourcePublicAPI } from '../types';
import { State } from './types';
import chartMetricSVG from '../assets/chart_metric.svg';
const toExpression = (
state: State,
frame: FramePublicAPI,
datasourceLayers: Record<string, DatasourcePublicAPI>,
mode: 'reduced' | 'full' = 'full'
): Ast | null => {
if (!state.accessor) {
return null;
}
const [datasource] = Object.values(frame.datasourceLayers);
const [datasource] = Object.values(datasourceLayers);
const operation = datasource && datasource.getOperationForColumnId(state.accessor);
return {
@ -39,7 +39,7 @@ const toExpression = (
};
};
export const metricVisualization: Visualization<State, PersistableState> = {
export const metricVisualization: Visualization<State> = {
id: 'lnsMetric',
visualizationTypes: [
@ -88,8 +88,6 @@ export const metricVisualization: Visualization<State, PersistableState> = {
);
},
getPersistableState: (state) => state,
getConfiguration(props) {
return {
groups: [
@ -106,8 +104,8 @@ export const metricVisualization: Visualization<State, PersistableState> = {
},
toExpression,
toPreviewExpression: (state: State, frame: FramePublicAPI) =>
toExpression(state, frame, 'reduced'),
toPreviewExpression: (state, datasourceLayers) =>
toExpression(state, datasourceLayers, 'reduced'),
setDimension({ prevState, columnId }) {
return { ...prevState, accessor: columnId };

View file

@ -13,5 +13,3 @@ export interface MetricConfig extends State {
title: string;
mode: 'reduced' | 'full';
}
export type PersistableState = State;

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Filter } from 'src/plugins/data/public';
import { extractFilterReferences, injectFilterReferences } from './filter_references';
import { FilterStateStore } from 'src/plugins/data/common';
describe('filter saved object references', () => {
const filters: Filter[] = [
{
$state: { store: FilterStateStore.APP_STATE },
meta: {
alias: null,
disabled: false,
index: '90943e30-9a47-11e8-b64d-95841ca0b247',
key: 'geo.src',
negate: true,
params: { query: 'CN' },
type: 'phrase',
},
query: { match_phrase: { 'geo.src': 'CN' } },
},
{
$state: { store: FilterStateStore.APP_STATE },
meta: {
alias: null,
disabled: false,
index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
key: 'geoip.country_iso_code',
negate: true,
params: { query: 'US' },
type: 'phrase',
},
query: { match_phrase: { 'geoip.country_iso_code': 'US' } },
},
];
it('should create two index-pattern references', () => {
const { references } = extractFilterReferences(filters);
expect(references).toMatchInlineSnapshot(`
Array [
Object {
"id": "90943e30-9a47-11e8-b64d-95841ca0b247",
"name": "filter-index-pattern-0",
"type": "index-pattern",
},
Object {
"id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
"name": "filter-index-pattern-1",
"type": "index-pattern",
},
]
`);
});
it('should restore the same filter after extracting and injecting', () => {
const { persistableFilters, references } = extractFilterReferences(filters);
expect(injectFilterReferences(persistableFilters, references)).toEqual(filters);
});
it('should ignore other references', () => {
const { persistableFilters, references } = extractFilterReferences(filters);
expect(
injectFilterReferences(persistableFilters, [
{ type: 'index-pattern', id: '1234', name: 'some other index pattern' },
...references,
])
).toEqual(filters);
});
it('should inject other ids if references change', () => {
const { persistableFilters, references } = extractFilterReferences(filters);
expect(
injectFilterReferences(
persistableFilters,
references.map((reference, index) => ({ ...reference, id: `overwritten-id-${index}` }))
)
).toEqual([
{
...filters[0],
meta: {
...filters[0].meta,
index: 'overwritten-id-0',
},
},
{
...filters[1],
meta: {
...filters[1].meta,
index: 'overwritten-id-1',
},
},
]);
});
});

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Filter } from 'src/plugins/data/public';
import { SavedObjectReference } from 'kibana/public';
import { PersistableFilter } from '../../common';
export function extractFilterReferences(
filters: Filter[]
): { persistableFilters: PersistableFilter[]; references: SavedObjectReference[] } {
const references: SavedObjectReference[] = [];
const persistableFilters = filters.map((filterRow, i) => {
if (!filterRow.meta || !filterRow.meta.index) {
return filterRow;
}
const refName = `filter-index-pattern-${i}`;
references.push({
name: refName,
type: 'index-pattern',
id: filterRow.meta.index,
});
return {
...filterRow,
meta: {
...filterRow.meta,
indexRefName: refName,
index: undefined,
},
};
});
return { persistableFilters, references };
}
export function injectFilterReferences(
filters: PersistableFilter[],
references: SavedObjectReference[]
) {
return filters.map((filterRow) => {
if (!filterRow.meta || !filterRow.meta.indexRefName) {
return filterRow as Filter;
}
const { indexRefName, ...metaRest } = filterRow.meta;
const reference = references.find((ref) => ref.name === indexRefName);
if (!reference) {
throw new Error(`Could not find reference for ${indexRefName}`);
}
return {
...filterRow,
meta: { ...metaRest, index: reference.id },
};
});
}

View file

@ -5,3 +5,4 @@
*/
export * from './saved_object_store';
export * from './filter_references';

View file

@ -30,11 +30,8 @@ describe('LensStore', () => {
title: 'Hello',
description: 'My doc',
visualizationType: 'bar',
expression: '',
references: [],
state: {
datasourceMetaData: {
filterableIndexPatterns: [],
},
datasourceStates: {
indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' },
},
@ -49,11 +46,8 @@ describe('LensStore', () => {
title: 'Hello',
description: 'My doc',
visualizationType: 'bar',
expression: '',
references: [],
state: {
datasourceMetaData: {
filterableIndexPatterns: [],
},
datasourceStates: {
indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' },
},
@ -64,21 +58,25 @@ describe('LensStore', () => {
});
expect(client.create).toHaveBeenCalledTimes(1);
expect(client.create).toHaveBeenCalledWith('lens', {
title: 'Hello',
description: 'My doc',
visualizationType: 'bar',
expression: '',
state: {
datasourceMetaData: { filterableIndexPatterns: [] },
datasourceStates: {
indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' },
expect(client.create).toHaveBeenCalledWith(
'lens',
{
title: 'Hello',
description: 'My doc',
visualizationType: 'bar',
state: {
datasourceStates: {
indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' },
},
visualization: { x: 'foo', y: 'baz' },
query: { query: '', language: 'lucene' },
filters: [],
},
visualization: { x: 'foo', y: 'baz' },
query: { query: '', language: 'lucene' },
filters: [],
},
});
{
references: [],
}
);
});
test('updates and returns a visualization document', async () => {
@ -87,9 +85,8 @@ describe('LensStore', () => {
id: 'Gandalf',
title: 'Even the very wise cannot see all ends.',
visualizationType: 'line',
expression: '',
references: [],
state: {
datasourceMetaData: { filterableIndexPatterns: [] },
datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } },
visualization: { gear: ['staff', 'pointy hat'] },
query: { query: '', language: 'lucene' },
@ -101,9 +98,8 @@ describe('LensStore', () => {
id: 'Gandalf',
title: 'Even the very wise cannot see all ends.',
visualizationType: 'line',
expression: '',
references: [],
state: {
datasourceMetaData: { filterableIndexPatterns: [] },
datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } },
visualization: { gear: ['staff', 'pointy hat'] },
query: { query: '', language: 'lucene' },
@ -116,22 +112,21 @@ describe('LensStore', () => {
{
type: 'lens',
id: 'Gandalf',
references: [],
attributes: {
title: null,
visualizationType: null,
expression: null,
state: null,
},
},
{
type: 'lens',
id: 'Gandalf',
references: [],
attributes: {
title: 'Even the very wise cannot see all ends.',
visualizationType: 'line',
expression: '',
state: {
datasourceMetaData: { filterableIndexPatterns: [] },
datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } },
visualization: { gear: ['staff', 'pointy hat'] },
query: { query: '', language: 'lucene' },

View file

@ -4,8 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SavedObjectAttributes, SavedObjectsClientContract } from 'kibana/public';
import { Query, Filter } from '../../../../../src/plugins/data/public';
import {
SavedObjectAttributes,
SavedObjectsClientContract,
SavedObjectReference,
} from 'kibana/public';
import { Query } from '../../../../../src/plugins/data/public';
import { PersistableFilter } from '../../common';
export interface Document {
id?: string;
@ -13,16 +18,13 @@ export interface Document {
visualizationType: string | null;
title: string;
description?: string;
expression: string | null;
state: {
datasourceMetaData: {
filterableIndexPatterns: Array<{ id: string; title: string }>;
};
datasourceStates: Record<string, unknown>;
visualization: unknown;
query: Query;
filters: Filter[];
filters: PersistableFilter[];
};
references: SavedObjectReference[];
}
export const DOC_TYPE = 'lens';
@ -45,14 +47,16 @@ export class SavedObjectIndexStore implements SavedObjectStore {
}
async save(vis: Document) {
const { id, type, ...rest } = vis;
const { id, type, references, ...rest } = vis;
// TODO: SavedObjectAttributes should support this kind of object,
// remove this workaround when SavedObjectAttributes is updated.
const attributes = (rest as unknown) as SavedObjectAttributes;
const result = await (id
? this.safeUpdate(id, attributes)
: this.client.create(DOC_TYPE, attributes));
? this.safeUpdate(id, attributes, references)
: this.client.create(DOC_TYPE, attributes, {
references,
}));
return { ...vis, id: result.id };
}
@ -63,21 +67,25 @@ export class SavedObjectIndexStore implements SavedObjectStore {
// deleted subtrees make it back into the object after a load.
// This function fixes this by doing two updates - one to empty out the document setting
// every key to null, and a second one to load the new content.
private async safeUpdate(id: string, attributes: SavedObjectAttributes) {
private async safeUpdate(
id: string,
attributes: SavedObjectAttributes,
references: SavedObjectReference[]
) {
const resetAttributes: SavedObjectAttributes = {};
Object.keys(attributes).forEach((key) => {
resetAttributes[key] = null;
});
return (
await this.client.bulkUpdate([
{ type: DOC_TYPE, id, attributes: resetAttributes },
{ type: DOC_TYPE, id, attributes },
{ type: DOC_TYPE, id, attributes: resetAttributes, references },
{ type: DOC_TYPE, id, attributes, references },
])
).savedObjects[1];
}
async load(id: string): Promise<Document> {
const { type, attributes, error } = await this.client.get(DOC_TYPE, id);
const { type, attributes, references, error } = await this.client.get(DOC_TYPE, id);
if (error) {
throw error;
@ -85,6 +93,7 @@ export class SavedObjectIndexStore implements SavedObjectStore {
return {
...(attributes as SavedObjectAttributes),
references,
id,
type,
} as Document;

View file

@ -31,7 +31,7 @@ const bucketedOperations = (op: OperationMetadata) => op.isBucketed;
const numberMetricOperations = (op: OperationMetadata) =>
!op.isBucketed && op.dataType === 'number';
export const pieVisualization: Visualization<PieVisualizationState, PieVisualizationState> = {
export const pieVisualization: Visualization<PieVisualizationState> = {
id: 'lnsPie',
visualizationTypes: [
@ -91,8 +91,6 @@ export const pieVisualization: Visualization<PieVisualizationState, PieVisualiza
);
},
getPersistableState: (state) => state,
getSuggestions: suggestions,
getConfiguration({ state, frame, layerId }) {

View file

@ -5,21 +5,24 @@
*/
import { Ast } from '@kbn/interpreter/common';
import { FramePublicAPI, Operation } from '../types';
import { Operation, DatasourcePublicAPI } from '../types';
import { DEFAULT_PERCENT_DECIMALS } from './constants';
import { PieVisualizationState } from './types';
export function toExpression(state: PieVisualizationState, frame: FramePublicAPI) {
return expressionHelper(state, frame, false);
export function toExpression(
state: PieVisualizationState,
datasourceLayers: Record<string, DatasourcePublicAPI>
) {
return expressionHelper(state, datasourceLayers, false);
}
function expressionHelper(
state: PieVisualizationState,
frame: FramePublicAPI,
datasourceLayers: Record<string, DatasourcePublicAPI>,
isPreview: boolean
): Ast | null {
const layer = state.layers[0];
const datasource = frame.datasourceLayers[layer.layerId];
const datasource = datasourceLayers[layer.layerId];
const operations = layer.groups
.map((columnId) => ({ columnId, operation: datasource.getOperationForColumnId(columnId) }))
.filter((o): o is { columnId: string; operation: Operation } => !!o.operation);
@ -50,6 +53,9 @@ function expressionHelper(
};
}
export function toPreviewExpression(state: PieVisualizationState, frame: FramePublicAPI) {
return expressionHelper(state, frame, true);
export function toPreviewExpression(
state: PieVisualizationState,
datasourceLayers: Record<string, DatasourcePublicAPI>
) {
return expressionHelper(state, datasourceLayers, true);
}

View file

@ -7,6 +7,7 @@
import { Ast } from '@kbn/interpreter/common';
import { IconType } from '@elastic/eui/src/components/icon/icon';
import { CoreSetup } from 'kibana/public';
import { SavedObjectReference } from 'kibana/public';
import {
ExpressionRendererEvent,
IInterpreterRenderHandlers,
@ -30,7 +31,6 @@ export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat;
export interface PublicAPIProps<T> {
state: T;
layerId: string;
dateRange: DateRange;
}
export interface EditorFrameProps {
@ -44,8 +44,9 @@ export interface EditorFrameProps {
// Frame loader (app or embeddable) is expected to call this when it loads and updates
// This should be replaced with a top-down state
onChange: (newState: {
filterableIndexPatterns: DatasourceMetaData['filterableIndexPatterns'];
filterableIndexPatterns: string[];
doc: Document;
isSaveable: boolean;
}) => void;
showNoDataPopover: () => void;
}
@ -57,9 +58,7 @@ export interface EditorFrameInstance {
export interface EditorFrameSetup {
// generic type on the API functions to pull the "unknown vs. specific type" error into the implementation
registerDatasource: <T, P>(datasource: Datasource<T, P> | Promise<Datasource<T, P>>) => void;
registerVisualization: <T, P>(
visualization: Visualization<T, P> | Promise<Visualization<T, P>>
) => void;
registerVisualization: <T>(visualization: Visualization<T> | Promise<Visualization<T>>) => void;
}
export interface EditorFrameStart {
@ -131,10 +130,6 @@ export interface DatasourceSuggestion<T = unknown> {
keptLayerIds: string[];
}
export interface DatasourceMetaData {
filterableIndexPatterns: Array<{ id: string; title: string }>;
}
export type StateSetter<T> = (newState: T | ((prevState: T) => T)) => void;
/**
@ -146,10 +141,10 @@ export interface Datasource<T = unknown, P = unknown> {
// For initializing, either from an empty state or from persisted state
// Because this will be called at runtime, state might have a type of `any` and
// datasources should validate their arguments
initialize: (state?: P) => Promise<T>;
initialize: (state?: P, savedObjectReferences?: SavedObjectReference[]) => Promise<T>;
// Given the current state, which parts should be saved?
getPersistableState: (state: T) => P;
getPersistableState: (state: T) => { state: P; savedObjectReferences: SavedObjectReference[] };
insertLayer: (state: T, newLayerId: string) => T;
removeLayer: (state: T, layerId: string) => T;
@ -166,8 +161,6 @@ export interface Datasource<T = unknown, P = unknown> {
toExpression: (state: T, layerId: string) => Ast | string | null;
getMetaData: (state: T) => DatasourceMetaData;
getDatasourceSuggestionsForField: (state: T, field: unknown) => Array<DatasourceSuggestion<T>>;
getDatasourceSuggestionsFromCurrentState: (state: T) => Array<DatasourceSuggestion<T>>;
@ -408,7 +401,7 @@ export interface VisualizationType {
label: string;
}
export interface Visualization<T = unknown, P = unknown> {
export interface Visualization<T = unknown> {
/** Plugin ID, such as "lnsXY" */
id: string;
@ -418,11 +411,7 @@ export interface Visualization<T = unknown, P = unknown> {
* - Loadingn from a saved visualization
* - When using suggestions, the suggested state is passed in
*/
initialize: (frame: FramePublicAPI, state?: P) => T;
/**
* Can remove any state that should not be persisted to saved object, such as UI state
*/
getPersistableState: (state: T) => P;
initialize: (frame: FramePublicAPI, state?: T) => T;
/**
* Visualizations must provide at least one type for the chart switcher,
@ -504,12 +493,18 @@ export interface Visualization<T = unknown, P = unknown> {
*/
getSuggestions: (context: SuggestionRequest<T>) => Array<VisualizationSuggestion<T>>;
toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null;
toExpression: (
state: T,
datasourceLayers: Record<string, DatasourcePublicAPI>
) => Ast | string | null;
/**
* Expression to render a preview version of the chart in very constrained space.
* If there is no expression provided, the preview icon is used.
*/
toPreviewExpression?: (state: T, frame: FramePublicAPI) => Ast | string | null;
toPreviewExpression?: (
state: T,
datasourceLayers: Record<string, DatasourcePublicAPI>
) => Ast | string | null;
}
export interface LensFilterEvent {

View file

@ -53,7 +53,7 @@ describe('#toExpression', () => {
},
],
},
frame
frame.datasourceLayers
)
).toMatchSnapshot();
});
@ -74,7 +74,7 @@ describe('#toExpression', () => {
},
],
},
frame
frame.datasourceLayers
) as Ast).chain[0].arguments.fittingFunction[0]
).toEqual('None');
});
@ -94,7 +94,7 @@ describe('#toExpression', () => {
},
],
},
frame
frame.datasourceLayers
) as Ast;
expect(expression.chain[0].arguments.showXAxisTitle[0]).toBe(true);
expect(expression.chain[0].arguments.showYAxisTitle[0]).toBe(true);
@ -116,7 +116,7 @@ describe('#toExpression', () => {
},
],
},
frame
frame.datasourceLayers
)
).toBeNull();
});
@ -137,7 +137,7 @@ describe('#toExpression', () => {
},
],
},
frame
frame.datasourceLayers
)
).toBeNull();
});
@ -157,7 +157,7 @@ describe('#toExpression', () => {
},
],
},
frame
frame.datasourceLayers
)! as Ast;
expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('b');
@ -191,7 +191,7 @@ describe('#toExpression', () => {
},
],
},
frame
frame.datasourceLayers
) as Ast;
expect(
(expression.chain[0].arguments.tickLabelsVisibilitySettings[0] as Ast).chain[0].arguments
@ -216,7 +216,7 @@ describe('#toExpression', () => {
},
],
},
frame
frame.datasourceLayers
) as Ast;
expect(
(expression.chain[0].arguments.gridlinesVisibilitySettings[0] as Ast).chain[0].arguments

View file

@ -7,13 +7,16 @@
import { Ast } from '@kbn/interpreter/common';
import { ScaleType } from '@elastic/charts';
import { State, LayerConfig } from './types';
import { FramePublicAPI, OperationMetadata } from '../types';
import { OperationMetadata, DatasourcePublicAPI } from '../types';
interface ValidLayer extends LayerConfig {
xAccessor: NonNullable<LayerConfig['xAccessor']>;
}
export const toExpression = (state: State, frame: FramePublicAPI): Ast | null => {
export const toExpression = (
state: State,
datasourceLayers: Record<string, DatasourcePublicAPI>
): Ast | null => {
if (!state || !state.layers.length) {
return null;
}
@ -21,19 +24,20 @@ export const toExpression = (state: State, frame: FramePublicAPI): Ast | null =>
const metadata: Record<string, Record<string, OperationMetadata | null>> = {};
state.layers.forEach((layer) => {
metadata[layer.layerId] = {};
const datasource = frame.datasourceLayers[layer.layerId];
const datasource = datasourceLayers[layer.layerId];
datasource.getTableSpec().forEach((column) => {
const operation = frame.datasourceLayers[layer.layerId].getOperationForColumnId(
column.columnId
);
const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId);
metadata[layer.layerId][column.columnId] = operation;
});
});
return buildExpression(state, metadata, frame);
return buildExpression(state, metadata, datasourceLayers);
};
export function toPreviewExpression(state: State, frame: FramePublicAPI) {
export function toPreviewExpression(
state: State,
datasourceLayers: Record<string, DatasourcePublicAPI>
) {
return toExpression(
{
...state,
@ -44,7 +48,7 @@ export function toPreviewExpression(state: State, frame: FramePublicAPI) {
isVisible: false,
},
},
frame
datasourceLayers
);
}
@ -77,7 +81,7 @@ export function getScaleType(metadata: OperationMetadata | null, defaultScale: S
export const buildExpression = (
state: State,
metadata: Record<string, Record<string, OperationMetadata | null>>,
frame?: FramePublicAPI
datasourceLayers?: Record<string, DatasourcePublicAPI>
): Ast | null => {
const validLayers = state.layers.filter((layer): layer is ValidLayer =>
Boolean(layer.xAccessor && layer.accessors.length)
@ -149,8 +153,8 @@ export const buildExpression = (
layers: validLayers.map((layer) => {
const columnToLabel: Record<string, string> = {};
if (frame) {
const datasource = frame.datasourceLayers[layer.layerId];
if (datasourceLayers) {
const datasource = datasourceLayers[layer.layerId];
layer.accessors
.concat(layer.splitAccessor ? [layer.splitAccessor] : [])
.forEach((accessor) => {
@ -162,8 +166,8 @@ export const buildExpression = (
}
const xAxisOperation =
frame &&
frame.datasourceLayers[layer.layerId].getOperationForColumnId(layer.xAccessor);
datasourceLayers &&
datasourceLayers[layer.layerId].getOperationForColumnId(layer.xAccessor);
const isHistogramDimension = Boolean(
xAxisOperation &&

View file

@ -339,7 +339,6 @@ export interface XYState {
}
export type State = XYState;
export type PersistableState = XYState;
export const visualizationTypes: VisualizationType[] = [
{

View file

@ -157,12 +157,6 @@ describe('xy_visualization', () => {
});
});
describe('#getPersistableState', () => {
it('persists the state as given', () => {
expect(xyVisualization.getPersistableState(exampleState())).toEqual(exampleState());
});
});
describe('#removeLayer', () => {
it('removes the specified layer', () => {
const prevState: State = {

View file

@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n';
import { getSuggestions } from './xy_suggestions';
import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel';
import { Visualization, OperationMetadata, VisualizationType } from '../types';
import { State, PersistableState, SeriesType, visualizationTypes, LayerConfig } from './types';
import { State, SeriesType, visualizationTypes, LayerConfig } from './types';
import chartBarStackedSVG from '../assets/chart_bar_stacked.svg';
import chartMixedSVG from '../assets/chart_mixed_xy.svg';
import { isHorizontalChart } from './state_helpers';
@ -74,7 +74,7 @@ function getDescription(state?: State) {
};
}
export const xyVisualization: Visualization<State, PersistableState> = {
export const xyVisualization: Visualization<State> = {
id: 'lnsXY',
visualizationTypes,
@ -159,8 +159,6 @@ export const xyVisualization: Visualization<State, PersistableState> = {
);
},
getPersistableState: (state) => state,
getConfiguration(props) {
const layer = props.state.layers.find((l) => l.layerId === props.layerId)!;
return {

View file

@ -0,0 +1,188 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Lens migrations 7.10.0 references should produce a valid document 1`] = `
Object {
"attributes": Object {
"state": Object {
"datasourceStates": Object {
"indexpattern": Object {
"layers": Object {
"3b7791e9-326e-40d5-a787-b7594e48d906": Object {
"columnOrder": Array [
"77d8383e-f66e-471e-ae50-c427feedb5ba",
"a5c1b82d-51de-4448-a99d-6391432c3a03",
],
"columns": Object {
"77d8383e-f66e-471e-ae50-c427feedb5ba": Object {
"dataType": "string",
"isBucketed": true,
"label": "Top values of geoip.country_iso_code",
"operationType": "terms",
"params": Object {
"orderBy": Object {
"columnId": "a5c1b82d-51de-4448-a99d-6391432c3a03",
"type": "column",
},
"orderDirection": "desc",
"size": 5,
},
"scale": "ordinal",
"sourceField": "geoip.country_iso_code",
},
"a5c1b82d-51de-4448-a99d-6391432c3a03": Object {
"dataType": "number",
"isBucketed": false,
"label": "Count of records",
"operationType": "count",
"scale": "ratio",
"sourceField": "Records",
},
},
},
"9a27f85d-35a9-4246-81b2-48e7ee9b0707": Object {
"columnOrder": Array [
"96352896-c508-4fca-90d8-66e9ebfce621",
"4ce9b4c7-2ebf-4d48-8669-0ea69d973353",
],
"columns": Object {
"4ce9b4c7-2ebf-4d48-8669-0ea69d973353": Object {
"dataType": "number",
"isBucketed": false,
"label": "Count of records",
"operationType": "count",
"scale": "ratio",
"sourceField": "Records",
},
"96352896-c508-4fca-90d8-66e9ebfce621": Object {
"dataType": "string",
"isBucketed": true,
"label": "Top values of geo.src",
"operationType": "terms",
"params": Object {
"orderBy": Object {
"columnId": "4ce9b4c7-2ebf-4d48-8669-0ea69d973353",
"type": "column",
},
"orderDirection": "desc",
"size": 5,
},
"scale": "ordinal",
"sourceField": "geo.src",
},
},
},
},
},
},
"filters": Array [
Object {
"$state": Object {
"store": "appState",
},
"meta": Object {
"alias": null,
"disabled": false,
"index": undefined,
"indexRefName": "filter-index-pattern-0",
"key": "geo.src",
"negate": true,
"params": Object {
"query": "CN",
},
"type": "phrase",
},
"query": Object {
"match_phrase": Object {
"geo.src": "CN",
},
},
},
Object {
"$state": Object {
"store": "appState",
},
"meta": Object {
"alias": null,
"disabled": false,
"index": undefined,
"indexRefName": "filter-index-pattern-1",
"key": "geoip.country_iso_code",
"negate": true,
"params": Object {
"query": "US",
},
"type": "phrase",
},
"query": Object {
"match_phrase": Object {
"geoip.country_iso_code": "US",
},
},
},
],
"query": Object {
"language": "kuery",
"query": "NOT bytes > 5000",
},
"visualization": Object {
"fittingFunction": "None",
"layers": Array [
Object {
"accessors": Array [
"4ce9b4c7-2ebf-4d48-8669-0ea69d973353",
],
"layerId": "9a27f85d-35a9-4246-81b2-48e7ee9b0707",
"position": "top",
"seriesType": "bar",
"showGridlines": false,
"xAccessor": "96352896-c508-4fca-90d8-66e9ebfce621",
},
Object {
"accessors": Array [
"a5c1b82d-51de-4448-a99d-6391432c3a03",
],
"layerId": "3b7791e9-326e-40d5-a787-b7594e48d906",
"seriesType": "bar",
"xAccessor": "77d8383e-f66e-471e-ae50-c427feedb5ba",
},
],
"legend": Object {
"isVisible": true,
"position": "right",
},
"preferredSeriesType": "bar",
},
},
"title": "mylens",
"visualizationType": "lnsXY",
},
"references": Array [
Object {
"id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
"name": "indexpattern-datasource-current-indexpattern",
"type": "index-pattern",
},
Object {
"id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
"name": "indexpattern-datasource-layer-3b7791e9-326e-40d5-a787-b7594e48d906",
"type": "index-pattern",
},
Object {
"id": "90943e30-9a47-11e8-b64d-95841ca0b247",
"name": "indexpattern-datasource-layer-9a27f85d-35a9-4246-81b2-48e7ee9b0707",
"type": "index-pattern",
},
Object {
"id": "90943e30-9a47-11e8-b64d-95841ca0b247",
"name": "filter-index-pattern-0",
"type": "index-pattern",
},
Object {
"id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
"name": "filter-index-pattern-1",
"type": "index-pattern",
},
],
"type": "lens",
}
`;

View file

@ -278,4 +278,233 @@ describe('Lens migrations', () => {
expect(result).toEqual(input);
});
});
describe('7.10.0 references', () => {
const context = {} as SavedObjectMigrationContext;
const example = {
attributes: {
description: '',
expression:
'kibana\n| kibana_context query="{\\"query\\":\\"NOT bytes > 5000\\",\\"language\\":\\"kuery\\"}" \n filters="[{\\"meta\\":{\\"index\\":\\"90943e30-9a47-11e8-b64d-95841ca0b247\\",\\"alias\\":null,\\"negate\\":true,\\"disabled\\":false,\\"type\\":\\"phrase\\",\\"key\\":\\"geo.src\\",\\"params\\":{\\"query\\":\\"CN\\"}},\\"query\\":{\\"match_phrase\\":{\\"geo.src\\":\\"CN\\"}},\\"$state\\":{\\"store\\":\\"appState\\"}},{\\"meta\\":{\\"index\\":\\"ff959d40-b880-11e8-a6d9-e546fe2bba5f\\",\\"alias\\":null,\\"negate\\":true,\\"disabled\\":false,\\"type\\":\\"phrase\\",\\"key\\":\\"geoip.country_iso_code\\",\\"params\\":{\\"query\\":\\"US\\"}},\\"query\\":{\\"match_phrase\\":{\\"geoip.country_iso_code\\":\\"US\\"}},\\"$state\\":{\\"store\\":\\"appState\\"}}]"\n| lens_merge_tables layerIds="9a27f85d-35a9-4246-81b2-48e7ee9b0707"\n layerIds="3b7791e9-326e-40d5-a787-b7594e48d906" \n tables={esaggs index="90943e30-9a47-11e8-b64d-95841ca0b247" metricsAtAllLevels=true partialRows=true includeFormatHints=true aggConfigs="[{\\"id\\":\\"96352896-c508-4fca-90d8-66e9ebfce621\\",\\"enabled\\":true,\\"type\\":\\"terms\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"geo.src\\",\\"orderBy\\":\\"4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\",\\"order\\":\\"desc\\",\\"size\\":5,\\"otherBucket\\":false,\\"otherBucketLabel\\":\\"Other\\",\\"missingBucket\\":false,\\"missingBucketLabel\\":\\"Missing\\"}},{\\"id\\":\\"4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}}]" | lens_rename_columns idMap="{\\"col-0-96352896-c508-4fca-90d8-66e9ebfce621\\":{\\"label\\":\\"Top values of geo.src\\",\\"dataType\\":\\"string\\",\\"operationType\\":\\"terms\\",\\"scale\\":\\"ordinal\\",\\"sourceField\\":\\"geo.src\\",\\"isBucketed\\":true,\\"params\\":{\\"size\\":5,\\"orderBy\\":{\\"type\\":\\"column\\",\\"columnId\\":\\"4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\"},\\"orderDirection\\":\\"desc\\"},\\"id\\":\\"96352896-c508-4fca-90d8-66e9ebfce621\\"},\\"col-1-4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"operationType\\":\\"count\\",\\"isBucketed\\":false,\\"scale\\":\\"ratio\\",\\"sourceField\\":\\"Records\\",\\"id\\":\\"4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\"}}"}\n tables={esaggs index="ff959d40-b880-11e8-a6d9-e546fe2bba5f" metricsAtAllLevels=true partialRows=true includeFormatHints=true aggConfigs="[{\\"id\\":\\"77d8383e-f66e-471e-ae50-c427feedb5ba\\",\\"enabled\\":true,\\"type\\":\\"terms\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"geoip.country_iso_code\\",\\"orderBy\\":\\"a5c1b82d-51de-4448-a99d-6391432c3a03\\",\\"order\\":\\"desc\\",\\"size\\":5,\\"otherBucket\\":false,\\"otherBucketLabel\\":\\"Other\\",\\"missingBucket\\":false,\\"missingBucketLabel\\":\\"Missing\\"}},{\\"id\\":\\"a5c1b82d-51de-4448-a99d-6391432c3a03\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}}]" | lens_rename_columns idMap="{\\"col-0-77d8383e-f66e-471e-ae50-c427feedb5ba\\":{\\"label\\":\\"Top values of geoip.country_iso_code\\",\\"dataType\\":\\"string\\",\\"operationType\\":\\"terms\\",\\"scale\\":\\"ordinal\\",\\"sourceField\\":\\"geoip.country_iso_code\\",\\"isBucketed\\":true,\\"params\\":{\\"size\\":5,\\"orderBy\\":{\\"type\\":\\"column\\",\\"columnId\\":\\"a5c1b82d-51de-4448-a99d-6391432c3a03\\"},\\"orderDirection\\":\\"desc\\"},\\"id\\":\\"77d8383e-f66e-471e-ae50-c427feedb5ba\\"},\\"col-1-a5c1b82d-51de-4448-a99d-6391432c3a03\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"operationType\\":\\"count\\",\\"isBucketed\\":false,\\"scale\\":\\"ratio\\",\\"sourceField\\":\\"Records\\",\\"id\\":\\"a5c1b82d-51de-4448-a99d-6391432c3a03\\"}}"}\n| lens_xy_chart xTitle="Top values of geo.src" yTitle="Count of records" legend={lens_xy_legendConfig isVisible=true position="right"} fittingFunction="None" \n layers={lens_xy_layer layerId="9a27f85d-35a9-4246-81b2-48e7ee9b0707" hide=false xAccessor="96352896-c508-4fca-90d8-66e9ebfce621" yScaleType="linear" xScaleType="ordinal" isHistogram=false seriesType="bar" accessors="4ce9b4c7-2ebf-4d48-8669-0ea69d973353" columnToLabel="{\\"4ce9b4c7-2ebf-4d48-8669-0ea69d973353\\":\\"Count of records\\"}"}\n layers={lens_xy_layer layerId="3b7791e9-326e-40d5-a787-b7594e48d906" hide=false xAccessor="77d8383e-f66e-471e-ae50-c427feedb5ba" yScaleType="linear" xScaleType="ordinal" isHistogram=false seriesType="bar" accessors="a5c1b82d-51de-4448-a99d-6391432c3a03" columnToLabel="{\\"a5c1b82d-51de-4448-a99d-6391432c3a03\\":\\"Count of records [1]\\"}"}',
state: {
datasourceMetaData: {
filterableIndexPatterns: [
{ id: '90943e30-9a47-11e8-b64d-95841ca0b247', title: 'kibana_sample_data_logs' },
{ id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', title: 'kibana_sample_data_ecommerce' },
],
},
datasourceStates: {
indexpattern: {
currentIndexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
layers: {
'3b7791e9-326e-40d5-a787-b7594e48d906': {
columnOrder: [
'77d8383e-f66e-471e-ae50-c427feedb5ba',
'a5c1b82d-51de-4448-a99d-6391432c3a03',
],
columns: {
'77d8383e-f66e-471e-ae50-c427feedb5ba': {
dataType: 'string',
isBucketed: true,
label: 'Top values of geoip.country_iso_code',
operationType: 'terms',
params: {
orderBy: {
columnId: 'a5c1b82d-51de-4448-a99d-6391432c3a03',
type: 'column',
},
orderDirection: 'desc',
size: 5,
},
scale: 'ordinal',
sourceField: 'geoip.country_iso_code',
},
'a5c1b82d-51de-4448-a99d-6391432c3a03': {
dataType: 'number',
isBucketed: false,
label: 'Count of records',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
},
indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
},
'9a27f85d-35a9-4246-81b2-48e7ee9b0707': {
columnOrder: [
'96352896-c508-4fca-90d8-66e9ebfce621',
'4ce9b4c7-2ebf-4d48-8669-0ea69d973353',
],
columns: {
'4ce9b4c7-2ebf-4d48-8669-0ea69d973353': {
dataType: 'number',
isBucketed: false,
label: 'Count of records',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
'96352896-c508-4fca-90d8-66e9ebfce621': {
dataType: 'string',
isBucketed: true,
label: 'Top values of geo.src',
operationType: 'terms',
params: {
orderBy: {
columnId: '4ce9b4c7-2ebf-4d48-8669-0ea69d973353',
type: 'column',
},
orderDirection: 'desc',
size: 5,
},
scale: 'ordinal',
sourceField: 'geo.src',
},
},
indexPatternId: '90943e30-9a47-11e8-b64d-95841ca0b247',
},
},
},
},
filters: [
{
$state: { store: 'appState' },
meta: {
alias: null,
disabled: false,
index: '90943e30-9a47-11e8-b64d-95841ca0b247',
key: 'geo.src',
negate: true,
params: { query: 'CN' },
type: 'phrase',
},
query: { match_phrase: { 'geo.src': 'CN' } },
},
{
$state: { store: 'appState' },
meta: {
alias: null,
disabled: false,
index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
key: 'geoip.country_iso_code',
negate: true,
params: { query: 'US' },
type: 'phrase',
},
query: { match_phrase: { 'geoip.country_iso_code': 'US' } },
},
],
query: { language: 'kuery', query: 'NOT bytes > 5000' },
visualization: {
fittingFunction: 'None',
layers: [
{
accessors: ['4ce9b4c7-2ebf-4d48-8669-0ea69d973353'],
layerId: '9a27f85d-35a9-4246-81b2-48e7ee9b0707',
position: 'top',
seriesType: 'bar',
showGridlines: false,
xAccessor: '96352896-c508-4fca-90d8-66e9ebfce621',
},
{
accessors: ['a5c1b82d-51de-4448-a99d-6391432c3a03'],
layerId: '3b7791e9-326e-40d5-a787-b7594e48d906',
seriesType: 'bar',
xAccessor: '77d8383e-f66e-471e-ae50-c427feedb5ba',
},
],
legend: { isVisible: true, position: 'right' },
preferredSeriesType: 'bar',
},
},
title: 'mylens',
visualizationType: 'lnsXY',
},
type: 'lens',
};
it('should remove expression', () => {
const result = migrations['7.10.0'](example, context);
expect(result.attributes.expression).toBeUndefined();
});
it('should list references for layers', () => {
const result = migrations['7.10.0'](example, context);
expect(
result.references?.find(
(ref) => ref.name === 'indexpattern-datasource-layer-3b7791e9-326e-40d5-a787-b7594e48d906'
)?.id
).toEqual('ff959d40-b880-11e8-a6d9-e546fe2bba5f');
expect(
result.references?.find(
(ref) => ref.name === 'indexpattern-datasource-layer-9a27f85d-35a9-4246-81b2-48e7ee9b0707'
)?.id
).toEqual('90943e30-9a47-11e8-b64d-95841ca0b247');
});
it('should remove index pattern ids from layers', () => {
const result = migrations['7.10.0'](example, context);
expect(
result.attributes.state.datasourceStates.indexpattern.layers[
'3b7791e9-326e-40d5-a787-b7594e48d906'
].indexPatternId
).toBeUndefined();
expect(
result.attributes.state.datasourceStates.indexpattern.layers[
'9a27f85d-35a9-4246-81b2-48e7ee9b0707'
].indexPatternId
).toBeUndefined();
});
it('should remove datsource meta data', () => {
const result = migrations['7.10.0'](example, context);
expect(result.attributes.state.datasourceMetaData).toBeUndefined();
});
it('should list references for filters', () => {
const result = migrations['7.10.0'](example, context);
expect(result.references?.find((ref) => ref.name === 'filter-index-pattern-0')?.id).toEqual(
'90943e30-9a47-11e8-b64d-95841ca0b247'
);
expect(result.references?.find((ref) => ref.name === 'filter-index-pattern-1')?.id).toEqual(
'ff959d40-b880-11e8-a6d9-e546fe2bba5f'
);
});
it('should remove index pattern ids from filters', () => {
const result = migrations['7.10.0'](example, context);
expect(result.attributes.state.filters[0].meta.index).toBeUndefined();
expect(result.attributes.state.filters[0].meta.indexRefName).toEqual(
'filter-index-pattern-0'
);
expect(result.attributes.state.filters[1].meta.index).toBeUndefined();
expect(result.attributes.state.filters[1].meta.indexRefName).toEqual(
'filter-index-pattern-1'
);
});
it('should list reference for current index pattern', () => {
const result = migrations['7.10.0'](example, context);
expect(
result.references?.find(
(ref) => ref.name === 'indexpattern-datasource-current-indexpattern'
)?.id
).toEqual('ff959d40-b880-11e8-a6d9-e546fe2bba5f');
});
it('should remove current index pattern id from datasource state', () => {
const result = migrations['7.10.0'](example, context);
expect(
result.attributes.state.datasourceStates.indexpattern.currentIndexPatternId
).toBeUndefined();
});
it('should produce a valid document', () => {
const result = migrations['7.10.0'](example, context);
// changes to the outcome of this are critical - this test is a safe guard to not introduce changes accidentally
// if this test fails, make extra sure it's expected
expect(result).toMatchSnapshot();
});
});
});

View file

@ -6,11 +6,16 @@
import { cloneDeep } from 'lodash';
import { fromExpression, toExpression, Ast, ExpressionFunctionAST } from '@kbn/interpreter/common';
import { SavedObjectMigrationMap, SavedObjectMigrationFn } from 'src/core/server';
import {
SavedObjectMigrationMap,
SavedObjectMigrationFn,
SavedObjectReference,
SavedObjectUnsanitizedDoc,
} from 'src/core/server';
import { Query, Filter } from 'src/plugins/data/public';
import { PersistableFilter } from '../common';
interface LensDocShape<VisualizationState = unknown> {
id?: string;
type?: string;
interface LensDocShapePre710<VisualizationState = unknown> {
visualizationType: string | null;
title: string;
expression: string | null;
@ -18,6 +23,32 @@ interface LensDocShape<VisualizationState = unknown> {
datasourceMetaData: {
filterableIndexPatterns: Array<{ id: string; title: string }>;
};
datasourceStates: {
// This is hardcoded as our only datasource
indexpattern: {
currentIndexPatternId: string;
layers: Record<
string,
{
columnOrder: string[];
columns: Record<string, unknown>;
indexPatternId: string;
}
>;
};
};
visualization: VisualizationState;
query: Query;
filters: Filter[];
};
}
interface LensDocShape<VisualizationState = unknown> {
id?: string;
type?: string;
visualizationType: string | null;
title: string;
state: {
datasourceStates: {
// This is hardcoded as our only datasource
indexpattern: {
@ -31,8 +62,8 @@ interface LensDocShape<VisualizationState = unknown> {
};
};
visualization: VisualizationState;
query: unknown;
filters: unknown[];
query: Query;
filters: PersistableFilter[];
};
}
@ -55,7 +86,10 @@ interface XYStatePost77 {
* Removes the `lens_auto_date` subexpression from a stored expression
* string. For example: aggConfigs={lens_auto_date aggConfigs="JSON string"}
*/
const removeLensAutoDate: SavedObjectMigrationFn<LensDocShape, LensDocShape> = (doc, context) => {
const removeLensAutoDate: SavedObjectMigrationFn<LensDocShapePre710, LensDocShapePre710> = (
doc,
context
) => {
const expression = doc.attributes.expression;
if (!expression) {
return doc;
@ -112,7 +146,10 @@ const removeLensAutoDate: SavedObjectMigrationFn<LensDocShape, LensDocShape> = (
/**
* Adds missing timeField arguments to esaggs in the Lens expression
*/
const addTimeFieldToEsaggs: SavedObjectMigrationFn<LensDocShape, LensDocShape> = (doc, context) => {
const addTimeFieldToEsaggs: SavedObjectMigrationFn<LensDocShapePre710, LensDocShapePre710> = (
doc,
context
) => {
const expression = doc.attributes.expression;
if (!expression) {
return doc;
@ -174,14 +211,14 @@ const addTimeFieldToEsaggs: SavedObjectMigrationFn<LensDocShape, LensDocShape> =
};
const removeInvalidAccessors: SavedObjectMigrationFn<
LensDocShape<XYStatePre77>,
LensDocShape<XYStatePost77>
LensDocShapePre710<XYStatePre77>,
LensDocShapePre710<XYStatePost77>
> = (doc) => {
const newDoc = cloneDeep(doc);
if (newDoc.attributes.visualizationType === 'lnsXY') {
const datasourceLayers = newDoc.attributes.state.datasourceStates.indexpattern.layers || {};
const xyState = newDoc.attributes.state.visualization;
(newDoc.attributes as LensDocShape<
(newDoc.attributes as LensDocShapePre710<
XYStatePost77
>).state.visualization.layers = xyState.layers.map((layer: XYLayerPre77) => {
const layerId = layer.layerId;
@ -197,9 +234,86 @@ const removeInvalidAccessors: SavedObjectMigrationFn<
return newDoc;
};
const extractReferences: SavedObjectMigrationFn<LensDocShapePre710, LensDocShape> = ({
attributes,
references,
...docMeta
}) => {
const savedObjectReferences: SavedObjectReference[] = [];
// add currently selected index pattern to reference list
savedObjectReferences.push({
type: 'index-pattern',
id: attributes.state.datasourceStates.indexpattern.currentIndexPatternId,
name: 'indexpattern-datasource-current-indexpattern',
});
// add layer index patterns to list and remove index pattern ids from layers
const persistableLayers: Record<
string,
Omit<
LensDocShapePre710['state']['datasourceStates']['indexpattern']['layers'][string],
'indexPatternId'
>
> = {};
Object.entries(attributes.state.datasourceStates.indexpattern.layers).forEach(
([layerId, { indexPatternId, ...persistableLayer }]) => {
savedObjectReferences.push({
type: 'index-pattern',
id: indexPatternId,
name: `indexpattern-datasource-layer-${layerId}`,
});
persistableLayers[layerId] = persistableLayer;
}
);
// add filter index patterns to reference list and remove index pattern ids from filter definitions
const persistableFilters = attributes.state.filters.map((filterRow, i) => {
if (!filterRow.meta || !filterRow.meta.index) {
return filterRow;
}
const refName = `filter-index-pattern-${i}`;
savedObjectReferences.push({
name: refName,
type: 'index-pattern',
id: filterRow.meta.index,
});
return {
...filterRow,
meta: {
...filterRow.meta,
indexRefName: refName,
index: undefined,
},
};
});
// put together new saved object format
const newDoc: SavedObjectUnsanitizedDoc<LensDocShape> = {
...docMeta,
references: savedObjectReferences,
attributes: {
visualizationType: attributes.visualizationType,
title: attributes.title,
state: {
datasourceStates: {
indexpattern: {
layers: persistableLayers,
},
},
visualization: attributes.state.visualization,
query: attributes.state.query,
filters: persistableFilters,
},
},
};
return newDoc;
};
export const migrations: SavedObjectMigrationMap = {
'7.7.0': removeInvalidAccessors,
// The order of these migrations matter, since the timefield migration relies on the aggConfigs
// sitting directly on the esaggs as an argument and not a nested function (which lens_auto_date was).
'7.8.0': (doc, context) => addTimeFieldToEsaggs(removeLensAutoDate(doc, context), context),
'7.10.0': extractReferences,
};