[Lens] Move editorFrame state to redux (#100858)

Co-authored-by: Joe Reuter <johannes.reuter@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: dej611 <dej611@gmail.com>
This commit is contained in:
Marta Bondyra 2021-07-01 11:00:56 +02:00 committed by GitHub
parent 65ff74ff5a
commit 6e3df60aba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2754 additions and 3379 deletions

View file

@ -260,7 +260,6 @@ export const App = (props: {
color
) as unknown) as LensEmbeddableInput
}
isVisible={isSaveModalVisible}
onSave={() => {}}
onClose={() => setIsSaveModalVisible(false)}
/>

View file

@ -13,7 +13,13 @@ import { App } from './app';
import { LensAppProps, LensAppServices } from './types';
import { EditorFrameInstance, EditorFrameProps } from '../types';
import { Document } from '../persistence';
import { makeDefaultServices, mountWithProvider } from '../mocks';
import {
createMockDatasource,
createMockVisualization,
DatasourceMock,
makeDefaultServices,
mountWithProvider,
} from '../mocks';
import { I18nProvider } from '@kbn/i18n/react';
import {
SavedObjectSaveModal,
@ -25,7 +31,6 @@ import {
FilterManager,
IFieldType,
IIndexPattern,
IndexPattern,
Query,
} from '../../../../../src/plugins/data/public';
import { TopNavMenuData } from '../../../../../src/plugins/navigation/public';
@ -60,17 +65,41 @@ jest.mock('lodash', () => {
// const navigationStartMock = navigationPluginMock.createStartContract();
function createMockFrame(): jest.Mocked<EditorFrameInstance> {
return {
EditorFrameContainer: jest.fn((props: EditorFrameProps) => <div />),
};
}
const sessionIdSubject = new Subject<string>();
describe('Lens App', () => {
let defaultDoc: Document;
let defaultSavedObjectId: string;
const mockDatasource: DatasourceMock = createMockDatasource('testDatasource');
const mockDatasource2: DatasourceMock = createMockDatasource('testDatasource2');
const datasourceMap = {
testDatasource2: mockDatasource2,
testDatasource: mockDatasource,
};
const mockVisualization = {
...createMockVisualization(),
id: 'testVis',
visualizationTypes: [
{
icon: 'empty',
id: 'testVis',
label: 'TEST1',
groupLabel: 'testVisGroup',
},
],
};
const visualizationMap = {
testVis: mockVisualization,
};
function createMockFrame(): jest.Mocked<EditorFrameInstance> {
return {
EditorFrameContainer: jest.fn((props: EditorFrameProps) => <div />),
datasourceMap,
visualizationMap,
};
}
const navMenuItems = {
expectedSaveButton: { emphasize: true, testId: 'lnsApp_saveButton' },
@ -86,17 +115,19 @@ describe('Lens App', () => {
redirectToOrigin: jest.fn(),
onAppLeave: jest.fn(),
setHeaderActionMenu: jest.fn(),
datasourceMap,
visualizationMap,
};
}
async function mountWith({
props = makeDefaultProps(),
services = makeDefaultServices(sessionIdSubject),
storePreloadedState,
preloadedState,
}: {
props?: jest.Mocked<LensAppProps>;
services?: jest.Mocked<LensAppServices>;
storePreloadedState?: Partial<LensAppState>;
preloadedState?: Partial<LensAppState>;
}) {
const wrappingComponent: React.FC<{
children: React.ReactNode;
@ -110,9 +141,11 @@ describe('Lens App', () => {
const { instance, lensStore } = await mountWithProvider(
<App {...props} />,
services.data,
storePreloadedState,
wrappingComponent
{
data: services.data,
preloadedState,
},
{ wrappingComponent }
);
const frame = props.editorFrame as ReturnType<typeof createMockFrame>;
@ -139,8 +172,6 @@ describe('Lens App', () => {
Array [
Array [
Object {
"initialContext": undefined,
"onError": [Function],
"showNoDataPopover": [Function],
},
Object {},
@ -164,7 +195,7 @@ describe('Lens App', () => {
instance.update();
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
query: { query: '', language: 'lucene' },
filters: [pinnedFilter],
resolvedDateRange: {
@ -177,14 +208,6 @@ describe('Lens App', () => {
expect(services.data.query.filterManager.getFilters).not.toHaveBeenCalled();
});
it('displays errors from the frame in a toast', async () => {
const { instance, frame, services } = await mountWith({});
const onError = frame.EditorFrameContainer.mock.calls[0][0].onError;
onError({ message: 'error' });
instance.update();
expect(services.notifications.toasts.addDanger).toHaveBeenCalled();
});
describe('breadcrumbs', () => {
const breadcrumbDocSavedObjectId = defaultSavedObjectId;
const breadcrumbDoc = ({
@ -237,7 +260,7 @@ describe('Lens App', () => {
const { instance, lensStore } = await mountWith({
props,
services,
storePreloadedState: {
preloadedState: {
isLinkedToOriginatingApp: true,
},
});
@ -275,8 +298,8 @@ describe('Lens App', () => {
});
describe('persistence', () => {
it('loads a document and uses query and filters if initial input is provided', async () => {
const { instance, lensStore, services } = await mountWith({});
it('passes query and indexPatterns to TopNavMenu', async () => {
const { instance, lensStore, services } = await mountWith({ preloadedState: {} });
const document = ({
savedObjectId: defaultSavedObjectId,
state: {
@ -290,8 +313,6 @@ describe('Lens App', () => {
lensStore.dispatch(
setState({
query: ('fake query' as unknown) as Query,
indexPatternsForTopNav: ([{ id: '1' }] as unknown) as IndexPattern[],
lastKnownDoc: document,
persistedDoc: document,
})
);
@ -301,7 +322,7 @@ describe('Lens App', () => {
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
query: 'fake query',
indexPatterns: [{ id: '1' }],
indexPatterns: [{ id: 'mockip' }],
}),
{}
);
@ -332,16 +353,11 @@ describe('Lens App', () => {
}
async function save({
lastKnownDoc = {
references: [],
state: {
filters: [],
},
},
preloadedState,
initialSavedObjectId,
...saveProps
}: SaveProps & {
lastKnownDoc?: object;
preloadedState?: Partial<LensAppState>;
initialSavedObjectId?: string;
}) {
const props = {
@ -366,18 +382,14 @@ describe('Lens App', () => {
},
} as jest.ResolvedValue<Document>);
const { frame, instance, lensStore } = await mountWith({ services, props });
act(() => {
lensStore.dispatch(
setState({
isSaveable: true,
lastKnownDoc: { savedObjectId: initialSavedObjectId, ...lastKnownDoc } as Document,
})
);
const { frame, instance, lensStore } = await mountWith({
services,
props,
preloadedState: {
isSaveable: true,
...preloadedState,
},
});
instance.update();
expect(getButton(instance).disableButton).toEqual(false);
await act(async () => {
testSave(instance, { ...saveProps });
@ -399,7 +411,6 @@ describe('Lens App', () => {
act(() => {
lensStore.dispatch(
setState({
lastKnownDoc: ({ savedObjectId: 'will save this' } as unknown) as Document,
isSaveable: true,
})
);
@ -415,7 +426,6 @@ describe('Lens App', () => {
lensStore.dispatch(
setState({
isSaveable: true,
lastKnownDoc: ({ savedObjectId: 'will save this' } as unknown) as Document,
})
);
});
@ -455,7 +465,7 @@ describe('Lens App', () => {
const { instance } = await mountWith({
props,
services,
storePreloadedState: {
preloadedState: {
isLinkedToOriginatingApp: true,
},
});
@ -483,7 +493,7 @@ describe('Lens App', () => {
const { instance, services } = await mountWith({
props,
storePreloadedState: {
preloadedState: {
isLinkedToOriginatingApp: true,
},
});
@ -540,6 +550,7 @@ describe('Lens App', () => {
initialSavedObjectId: defaultSavedObjectId,
newCopyOnSave: true,
newTitle: 'hello there',
preloadedState: { persistedDoc: defaultDoc },
});
expect(services.attributeService.wrapAttributes).toHaveBeenCalledWith(
expect.objectContaining({
@ -559,10 +570,11 @@ describe('Lens App', () => {
});
it('saves existing docs', async () => {
const { props, services, instance, lensStore } = await save({
const { props, services, instance } = await save({
initialSavedObjectId: defaultSavedObjectId,
newCopyOnSave: false,
newTitle: 'hello there',
preloadedState: { persistedDoc: defaultDoc },
});
expect(services.attributeService.wrapAttributes).toHaveBeenCalledWith(
expect.objectContaining({
@ -576,22 +588,6 @@ describe('Lens App', () => {
await act(async () => {
instance.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
});
expect(lensStore.dispatch).toHaveBeenCalledWith({
payload: {
lastKnownDoc: expect.objectContaining({
savedObjectId: defaultSavedObjectId,
title: 'hello there',
}),
persistedDoc: expect.objectContaining({
savedObjectId: defaultSavedObjectId,
title: 'hello there',
}),
isLinkedToOriginatingApp: false,
},
type: 'app/setState',
});
expect(services.notifications.toasts.addSuccess).toHaveBeenCalledWith(
"Saved 'hello there'"
);
@ -602,18 +598,13 @@ describe('Lens App', () => {
services.attributeService.wrapAttributes = jest
.fn()
.mockRejectedValue({ message: 'failed' });
const { instance, props, lensStore } = await mountWith({ services });
act(() => {
lensStore.dispatch(
setState({
isSaveable: true,
lastKnownDoc: ({ id: undefined } as unknown) as Document,
})
);
const { instance, props } = await mountWith({
services,
preloadedState: {
isSaveable: true,
},
});
instance.update();
await act(async () => {
testSave(instance, { newCopyOnSave: false, newTitle: 'hello there' });
});
@ -655,22 +646,19 @@ describe('Lens App', () => {
initialSavedObjectId: defaultSavedObjectId,
newCopyOnSave: false,
newTitle: 'hello there2',
lastKnownDoc: {
expression: 'kibana 3',
state: {
filters: [pinned, unpinned],
},
preloadedState: {
persistedDoc: defaultDoc,
filters: [pinned, unpinned],
},
});
expect(services.attributeService.wrapAttributes).toHaveBeenCalledWith(
{
expect.objectContaining({
savedObjectId: defaultSavedObjectId,
title: 'hello there2',
expression: 'kibana 3',
state: {
state: expect.objectContaining({
filters: [unpinned],
},
},
}),
}),
true,
{ id: '5678', savedObjectId: defaultSavedObjectId }
);
@ -681,17 +669,13 @@ describe('Lens App', () => {
services.attributeService.wrapAttributes = jest
.fn()
.mockReturnValue(Promise.resolve({ savedObjectId: '123' }));
const { instance, lensStore } = await mountWith({ services });
await act(async () => {
lensStore.dispatch(
setState({
isSaveable: true,
lastKnownDoc: ({ savedObjectId: '123' } as unknown) as Document,
})
);
const { instance } = await mountWith({
services,
preloadedState: {
isSaveable: true,
persistedDoc: ({ savedObjectId: '123' } as unknown) as Document,
},
});
instance.update();
await act(async () => {
instance.setProps({ initialInput: { savedObjectId: '123' } });
getButton(instance).run(instance.getDOMNode());
@ -716,17 +700,7 @@ describe('Lens App', () => {
});
it('does not show the copy button on first save', async () => {
const { instance, lensStore } = await mountWith({});
await act(async () => {
lensStore.dispatch(
setState({
isSaveable: true,
lastKnownDoc: ({} as unknown) as Document,
})
);
});
instance.update();
const { instance } = await mountWith({ preloadedState: { isSaveable: true } });
await act(async () => getButton(instance).run(instance.getDOMNode()));
instance.update();
expect(instance.find(SavedObjectSaveModal).prop('showCopyOnSave')).toEqual(false);
@ -744,33 +718,18 @@ describe('Lens App', () => {
}
it('should be disabled when no data is available', async () => {
const { instance, lensStore } = await mountWith({});
await act(async () => {
lensStore.dispatch(
setState({
isSaveable: true,
lastKnownDoc: ({} as unknown) as Document,
})
);
});
instance.update();
const { instance } = await mountWith({ preloadedState: { isSaveable: true } });
expect(getButton(instance).disableButton).toEqual(true);
});
it('should disable download when not saveable', async () => {
const { instance, lensStore } = await mountWith({});
await act(async () => {
lensStore.dispatch(
setState({
lastKnownDoc: ({} as unknown) as Document,
isSaveable: false,
activeData: { layer1: { type: 'datatable', columns: [], rows: [] } },
})
);
const { instance } = await mountWith({
preloadedState: {
isSaveable: false,
activeData: { layer1: { type: 'datatable', columns: [], rows: [] } },
},
});
instance.update();
expect(getButton(instance).disableButton).toEqual(true);
});
@ -784,17 +743,13 @@ describe('Lens App', () => {
},
};
const { instance, lensStore } = await mountWith({ services });
await act(async () => {
lensStore.dispatch(
setState({
lastKnownDoc: ({} as unknown) as Document,
isSaveable: true,
activeData: { layer1: { type: 'datatable', columns: [], rows: [] } },
})
);
const { instance } = await mountWith({
services,
preloadedState: {
isSaveable: true,
activeData: { layer1: { type: 'datatable', columns: [], rows: [] } },
},
});
instance.update();
expect(getButton(instance).disableButton).toEqual(false);
});
});
@ -812,7 +767,7 @@ describe('Lens App', () => {
);
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
query: { query: '', language: 'lucene' },
resolvedDateRange: {
fromDate: '2021-01-10T04:00:00.000Z',
@ -822,49 +777,6 @@ describe('Lens App', () => {
});
});
it('updates the index patterns when the editor frame is changed', async () => {
const { instance, lensStore, services } = await mountWith({});
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
indexPatterns: [],
}),
{}
);
await act(async () => {
lensStore.dispatch(
setState({
indexPatternsForTopNav: [{ id: '1' }] as IndexPattern[],
lastKnownDoc: ({} as unknown) as Document,
isSaveable: true,
})
);
});
instance.update();
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
indexPatterns: [{ id: '1' }],
}),
{}
);
// Do it again to verify that the dirty checking is done right
await act(async () => {
lensStore.dispatch(
setState({
indexPatternsForTopNav: [{ id: '2' }] as IndexPattern[],
lastKnownDoc: ({} as unknown) as Document,
isSaveable: true,
})
);
});
instance.update();
expect(services.navigation.ui.TopNavMenu).toHaveBeenLastCalledWith(
expect.objectContaining({
indexPatterns: [{ id: '2' }],
}),
{}
);
});
it('updates the editor frame when the user changes query or time in the search bar', async () => {
const { instance, services, lensStore } = await mountWith({});
(services.data.query.timefilter.timefilter.calculateBounds as jest.Mock).mockReturnValue({
@ -892,7 +804,7 @@ describe('Lens App', () => {
});
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
query: { query: 'new', language: 'lucene' },
resolvedDateRange: {
fromDate: '2021-01-09T04:00:00.000Z',
@ -907,7 +819,7 @@ describe('Lens App', () => {
const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
filters: [],
}),
});
@ -918,7 +830,7 @@ describe('Lens App', () => {
);
instance.update();
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
filters: [esFilters.buildExistsFilter(field, indexPattern)],
}),
});
@ -928,7 +840,7 @@ describe('Lens App', () => {
const { instance, services, lensStore } = await mountWith({});
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
searchSessionId: `sessionId-1`,
}),
});
@ -942,7 +854,7 @@ describe('Lens App', () => {
instance.update();
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
searchSessionId: `sessionId-2`,
}),
});
@ -955,7 +867,7 @@ describe('Lens App', () => {
);
instance.update();
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
searchSessionId: `sessionId-3`,
}),
});
@ -968,7 +880,7 @@ describe('Lens App', () => {
);
instance.update();
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
searchSessionId: `sessionId-4`,
}),
});
@ -1105,7 +1017,7 @@ describe('Lens App', () => {
act(() => instance.find(services.navigation.ui.TopNavMenu).prop('onClearSavedQuery')!());
instance.update();
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
filters: [pinned],
}),
});
@ -1137,7 +1049,7 @@ describe('Lens App', () => {
});
instance.update();
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
searchSessionId: `sessionId-2`,
}),
});
@ -1162,30 +1074,12 @@ describe('Lens App', () => {
act(() => instance.find(services.navigation.ui.TopNavMenu).prop('onClearSavedQuery')!());
instance.update();
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
searchSessionId: `sessionId-4`,
}),
});
});
const mockUpdate = {
filterableIndexPatterns: [],
doc: {
title: '',
description: '',
visualizationType: '',
state: {
datasourceStates: {},
visualization: {},
filters: [],
query: { query: '', language: 'lucene' },
},
references: [],
},
isSaveable: true,
activeData: undefined,
};
it('updates the state if session id changes from the outside', async () => {
const services = makeDefaultServices(sessionIdSubject);
const { lensStore } = await mountWith({ props: undefined, services });
@ -1197,25 +1091,16 @@ describe('Lens App', () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
searchSessionId: `new-session-id`,
}),
});
});
it('does not update the searchSessionId when the state changes', async () => {
const { lensStore } = await mountWith({});
act(() => {
lensStore.dispatch(
setState({
indexPatternsForTopNav: [],
lastKnownDoc: mockUpdate.doc,
isSaveable: true,
})
);
});
const { lensStore } = await mountWith({ preloadedState: { isSaveable: true } });
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
lens: expect.objectContaining({
searchSessionId: `sessionId-1`,
}),
});
@ -1248,20 +1133,7 @@ describe('Lens App', () => {
visualize: { save: false, saveQuery: false, show: true },
},
};
const { instance, props, lensStore } = await mountWith({ services });
act(() => {
lensStore.dispatch(
setState({
indexPatternsForTopNav: [] as IndexPattern[],
lastKnownDoc: ({
savedObjectId: undefined,
references: [],
} as unknown) as Document,
isSaveable: true,
})
);
});
instance.update();
const { props } = await mountWith({ services, preloadedState: { isSaveable: true } });
const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });
expect(defaultLeave).toHaveBeenCalled();
@ -1269,14 +1141,14 @@ describe('Lens App', () => {
});
it('should confirm when leaving with an unsaved doc', async () => {
const { lensStore, props } = await mountWith({});
act(() => {
lensStore.dispatch(
setState({
lastKnownDoc: ({ savedObjectId: undefined, state: {} } as unknown) as Document,
isSaveable: true,
})
);
const { props } = await mountWith({
preloadedState: {
visualization: {
activeId: 'testVis',
state: {},
},
isSaveable: true,
},
});
const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });
@ -1285,18 +1157,15 @@ describe('Lens App', () => {
});
it('should confirm when leaving with unsaved changes to an existing doc', async () => {
const { lensStore, props } = await mountWith({});
act(() => {
lensStore.dispatch(
setState({
persistedDoc: defaultDoc,
lastKnownDoc: ({
savedObjectId: defaultSavedObjectId,
references: [],
} as unknown) as Document,
isSaveable: true,
})
);
const { props } = await mountWith({
preloadedState: {
persistedDoc: defaultDoc,
visualization: {
activeId: 'testVis',
state: {},
},
isSaveable: true,
},
});
const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });
@ -1305,15 +1174,23 @@ describe('Lens App', () => {
});
it('should not confirm when changes are saved', async () => {
const { lensStore, props } = await mountWith({});
act(() => {
lensStore.dispatch(
setState({
lastKnownDoc: defaultDoc,
persistedDoc: defaultDoc,
isSaveable: true,
})
);
const { props } = await mountWith({
preloadedState: {
persistedDoc: {
...defaultDoc,
state: {
...defaultDoc.state,
datasourceStates: { testDatasource: '' },
visualization: {},
},
},
isSaveable: true,
...(defaultDoc.state as Partial<LensAppState>),
visualization: {
activeId: 'testVis',
state: {},
},
},
});
const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });
@ -1321,16 +1198,13 @@ describe('Lens App', () => {
expect(confirmLeave).not.toHaveBeenCalled();
});
// not sure how to test it
it('should confirm when the latest doc is invalid', async () => {
const { lensStore, props } = await mountWith({});
act(() => {
lensStore.dispatch(
setState({
persistedDoc: defaultDoc,
lastKnownDoc: ({
savedObjectId: defaultSavedObjectId,
references: [],
} as unknown) as Document,
isSaveable: true,
})
);

View file

@ -10,8 +10,6 @@ import './app.scss';
import { isEqual } from 'lodash';
import React, { useState, useEffect, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { Toast } from 'kibana/public';
import { VisualizeFieldContext } from 'src/plugins/ui_actions/public';
import { EuiBreadcrumb } from '@elastic/eui';
import {
createKbnUrlStateStorage,
@ -24,8 +22,9 @@ import { LensAppProps, LensAppServices } from './types';
import { LensTopNavMenu } from './lens_top_nav';
import { LensByReferenceInput } from '../embeddable';
import { EditorFrameInstance } from '../types';
import { Document } from '../persistence/saved_object_store';
import {
setState as setAppState,
setState,
useLensSelector,
useLensDispatch,
LensAppState,
@ -36,6 +35,7 @@ import {
getLastKnownDocWithoutPinnedFilters,
runSaveLensVisualization,
} from './save_modal_container';
import { getSavedObjectFormat } from '../utils';
export type SaveProps = Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
returnToOrigin: boolean;
@ -54,7 +54,8 @@ export function App({
incomingState,
redirectToOrigin,
setHeaderActionMenu,
initialContext,
datasourceMap,
visualizationMap,
}: LensAppProps) {
const lensAppServices = useKibana<LensAppServices>().services;
@ -73,16 +74,69 @@ export function App({
const dispatch = useLensDispatch();
const dispatchSetState: DispatchSetState = useCallback(
(state: Partial<LensAppState>) => dispatch(setAppState(state)),
(state: Partial<LensAppState>) => dispatch(setState(state)),
[dispatch]
);
const appState = useLensSelector((state) => state.app);
const {
datasourceStates,
visualization,
filters,
query,
activeDatasourceId,
persistedDoc,
isLinkedToOriginatingApp,
searchSessionId,
isLoading,
isSaveable,
} = useLensSelector((state) => state.lens);
// Used to show a popover that guides the user towards changing the date range when no data is available.
const [indicateNoData, setIndicateNoData] = useState(false);
const [isSaveModalVisible, setIsSaveModalVisible] = useState(false);
const { lastKnownDoc } = appState;
const [lastKnownDoc, setLastKnownDoc] = useState<Document | undefined>(undefined);
useEffect(() => {
const activeVisualization = visualization.activeId && visualizationMap[visualization.activeId];
const activeDatasource =
activeDatasourceId && !datasourceStates[activeDatasourceId].isLoading
? datasourceMap[activeDatasourceId]
: undefined;
if (!activeDatasource || !activeVisualization || !visualization.state) {
return;
}
setLastKnownDoc(
// todo: that should be redux store selector
getSavedObjectFormat({
activeDatasources: Object.keys(datasourceStates).reduce(
(acc, datasourceId) => ({
...acc,
[datasourceId]: datasourceMap[datasourceId],
}),
{}
),
datasourceStates,
visualization,
filters,
query,
title: persistedDoc?.title || '',
description: persistedDoc?.description,
persistedId: persistedDoc?.savedObjectId,
})
);
}, [
persistedDoc?.title,
persistedDoc?.description,
persistedDoc?.savedObjectId,
datasourceStates,
visualization,
filters,
query,
activeDatasourceId,
datasourceMap,
visualizationMap,
]);
const showNoDataPopover = useCallback(() => {
setIndicateNoData(true);
@ -92,30 +146,17 @@ export function App({
if (indicateNoData) {
setIndicateNoData(false);
}
}, [
setIndicateNoData,
indicateNoData,
appState.indexPatternsForTopNav,
appState.searchSessionId,
]);
const onError = useCallback(
(e: { message: string }) =>
notifications.toasts.addDanger({
title: e.message,
}),
[notifications.toasts]
);
}, [setIndicateNoData, indicateNoData, searchSessionId]);
const getIsByValueMode = useCallback(
() =>
Boolean(
// Temporarily required until the 'by value' paradigm is default.
dashboardFeatureFlag.allowByValueEmbeddables &&
appState.isLinkedToOriginatingApp &&
isLinkedToOriginatingApp &&
!(initialInput as LensByReferenceInput)?.savedObjectId
),
[dashboardFeatureFlag.allowByValueEmbeddables, appState.isLinkedToOriginatingApp, initialInput]
[dashboardFeatureFlag.allowByValueEmbeddables, isLinkedToOriginatingApp, initialInput]
);
useEffect(() => {
@ -138,13 +179,11 @@ export function App({
onAppLeave((actions) => {
// Confirm when the user has made any changes to an existing doc
// or when the user has configured something without saving
if (
application.capabilities.visualize.save &&
!isEqual(
appState.persistedDoc?.state,
getLastKnownDocWithoutPinnedFilters(lastKnownDoc)?.state
) &&
(appState.isSaveable || appState.persistedDoc)
!isEqual(persistedDoc?.state, getLastKnownDocWithoutPinnedFilters(lastKnownDoc)?.state) &&
(isSaveable || persistedDoc)
) {
return actions.confirm(
i18n.translate('xpack.lens.app.unsavedWorkMessage', {
@ -158,19 +197,13 @@ export function App({
return actions.default();
}
});
}, [
onAppLeave,
lastKnownDoc,
appState.isSaveable,
appState.persistedDoc,
application.capabilities.visualize.save,
]);
}, [onAppLeave, lastKnownDoc, isSaveable, persistedDoc, application.capabilities.visualize.save]);
// Sync Kibana breadcrumbs any time the saved document's title changes
useEffect(() => {
const isByValueMode = getIsByValueMode();
const breadcrumbs: EuiBreadcrumb[] = [];
if (appState.isLinkedToOriginatingApp && getOriginatingAppName() && redirectToOrigin) {
if (isLinkedToOriginatingApp && getOriginatingAppName() && redirectToOrigin) {
breadcrumbs.push({
onClick: () => {
redirectToOrigin();
@ -193,10 +226,10 @@ export function App({
let currentDocTitle = i18n.translate('xpack.lens.breadcrumbsCreate', {
defaultMessage: 'Create',
});
if (appState.persistedDoc) {
if (persistedDoc) {
currentDocTitle = isByValueMode
? i18n.translate('xpack.lens.breadcrumbsByValue', { defaultMessage: 'Edit visualization' })
: appState.persistedDoc.title;
: persistedDoc.title;
}
breadcrumbs.push({ text: currentDocTitle });
chrome.setBreadcrumbs(breadcrumbs);
@ -207,39 +240,55 @@ export function App({
getIsByValueMode,
application,
chrome,
appState.isLinkedToOriginatingApp,
appState.persistedDoc,
isLinkedToOriginatingApp,
persistedDoc,
]);
const runSave = (saveProps: SaveProps, options: { saveToLibrary: boolean }) => {
return runSaveLensVisualization(
{
lastKnownDoc,
getIsByValueMode,
savedObjectsTagging,
initialInput,
redirectToOrigin,
persistedDoc: appState.persistedDoc,
onAppLeave,
redirectTo,
originatingApp: incomingState?.originatingApp,
...lensAppServices,
},
saveProps,
options
).then(
(newState) => {
if (newState) {
dispatchSetState(newState);
setIsSaveModalVisible(false);
const runSave = useCallback(
(saveProps: SaveProps, options: { saveToLibrary: boolean }) => {
return runSaveLensVisualization(
{
lastKnownDoc,
getIsByValueMode,
savedObjectsTagging,
initialInput,
redirectToOrigin,
persistedDoc,
onAppLeave,
redirectTo,
originatingApp: incomingState?.originatingApp,
...lensAppServices,
},
saveProps,
options
).then(
(newState) => {
if (newState) {
dispatchSetState(newState);
setIsSaveModalVisible(false);
}
},
() => {
// error is handled inside the modal
// so ignoring it here
}
},
() => {
// error is handled inside the modal
// so ignoring it here
}
);
};
);
},
[
incomingState?.originatingApp,
lastKnownDoc,
persistedDoc,
getIsByValueMode,
savedObjectsTagging,
initialInput,
redirectToOrigin,
onAppLeave,
redirectTo,
lensAppServices,
dispatchSetState,
setIsSaveModalVisible,
]
);
return (
<>
@ -253,64 +302,53 @@ export function App({
setIsSaveModalVisible={setIsSaveModalVisible}
setHeaderActionMenu={setHeaderActionMenu}
indicateNoData={indicateNoData}
datasourceMap={datasourceMap}
title={persistedDoc?.title}
/>
{(!appState.isAppLoading || appState.persistedDoc) && (
{(!isLoading || persistedDoc) && (
<MemoizedEditorFrameWrapper
editorFrame={editorFrame}
onError={onError}
showNoDataPopover={showNoDataPopover}
initialContext={initialContext}
/>
)}
</div>
<SaveModalContainer
isVisible={isSaveModalVisible}
lensServices={lensAppServices}
originatingApp={
appState.isLinkedToOriginatingApp ? incomingState?.originatingApp : undefined
}
isSaveable={appState.isSaveable}
runSave={runSave}
onClose={() => {
setIsSaveModalVisible(false);
}}
getAppNameFromId={() => getOriginatingAppName()}
lastKnownDoc={lastKnownDoc}
onAppLeave={onAppLeave}
persistedDoc={appState.persistedDoc}
initialInput={initialInput}
redirectTo={redirectTo}
redirectToOrigin={redirectToOrigin}
returnToOriginSwitchLabel={
getIsByValueMode() && initialInput
? i18n.translate('xpack.lens.app.updatePanel', {
defaultMessage: 'Update panel on {originatingAppName}',
values: { originatingAppName: getOriginatingAppName() },
})
: undefined
}
/>
{isSaveModalVisible && (
<SaveModalContainer
lensServices={lensAppServices}
originatingApp={isLinkedToOriginatingApp ? incomingState?.originatingApp : undefined}
isSaveable={isSaveable}
runSave={runSave}
onClose={() => {
setIsSaveModalVisible(false);
}}
getAppNameFromId={() => getOriginatingAppName()}
lastKnownDoc={lastKnownDoc}
onAppLeave={onAppLeave}
persistedDoc={persistedDoc}
initialInput={initialInput}
redirectTo={redirectTo}
redirectToOrigin={redirectToOrigin}
returnToOriginSwitchLabel={
getIsByValueMode() && initialInput
? i18n.translate('xpack.lens.app.updatePanel', {
defaultMessage: 'Update panel on {originatingAppName}',
values: { originatingAppName: getOriginatingAppName() },
})
: undefined
}
/>
)}
</>
);
}
const MemoizedEditorFrameWrapper = React.memo(function EditorFrameWrapper({
editorFrame,
onError,
showNoDataPopover,
initialContext,
}: {
editorFrame: EditorFrameInstance;
onError: (e: { message: string }) => Toast;
showNoDataPopover: () => void;
initialContext: VisualizeFieldContext | undefined;
}) {
const { EditorFrameContainer } = editorFrame;
return (
<EditorFrameContainer
onError={onError}
showNoDataPopover={showNoDataPopover}
initialContext={initialContext}
/>
);
return <EditorFrameContainer showNoDataPopover={showNoDataPopover} />;
});

View file

@ -7,21 +7,21 @@
import { isEqual } from 'lodash';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { TopNavMenuData } from '../../../../../src/plugins/navigation/public';
import { LensAppServices, LensTopNavActions, LensTopNavMenuProps } from './types';
import { downloadMultipleAs } from '../../../../../src/plugins/share/public';
import { trackUiEvent } from '../lens_ui_telemetry';
import { exporters } from '../../../../../src/plugins/data/public';
import { exporters, IndexPattern } from '../../../../../src/plugins/data/public';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import {
setState as setAppState,
setState,
useLensSelector,
useLensDispatch,
LensAppState,
DispatchSetState,
} from '../state_management';
import { getIndexPatternsObjects, getIndexPatternsIds } from '../utils';
function getLensTopNavConfig(options: {
showSaveAndReturn: boolean;
@ -127,6 +127,8 @@ export const LensTopNavMenu = ({
runSave,
onAppLeave,
redirectToOrigin,
datasourceMap,
title,
}: LensTopNavMenuProps) => {
const {
data,
@ -139,19 +141,52 @@ export const LensTopNavMenu = ({
const dispatch = useLensDispatch();
const dispatchSetState: DispatchSetState = React.useCallback(
(state: Partial<LensAppState>) => dispatch(setAppState(state)),
(state: Partial<LensAppState>) => dispatch(setState(state)),
[dispatch]
);
const [indexPatterns, setIndexPatterns] = useState<IndexPattern[]>([]);
const {
isSaveable,
isLinkedToOriginatingApp,
indexPatternsForTopNav,
query,
lastKnownDoc,
activeData,
savedQuery,
} = useLensSelector((state) => state.app);
activeDatasourceId,
datasourceStates,
} = useLensSelector((state) => state.lens);
useEffect(() => {
const activeDatasource =
datasourceMap && activeDatasourceId && !datasourceStates[activeDatasourceId].isLoading
? datasourceMap[activeDatasourceId]
: undefined;
if (!activeDatasource) {
return;
}
const indexPatternIds = getIndexPatternsIds({
activeDatasources: Object.keys(datasourceStates).reduce(
(acc, datasourceId) => ({
...acc,
[datasourceId]: datasourceMap[datasourceId],
}),
{}
),
datasourceStates,
});
const hasIndexPatternsChanged =
indexPatterns.length !== indexPatternIds.length ||
indexPatternIds.some((id) => !indexPatterns.find((indexPattern) => indexPattern.id === id));
// Update the cached index patterns if the user made a change to any of them
if (hasIndexPatternsChanged) {
getIndexPatternsObjects(indexPatternIds, data.indexPatterns).then(
({ indexPatterns: indexPatternObjects }) => {
setIndexPatterns(indexPatternObjects);
}
);
}
}, [datasourceStates, activeDatasourceId, data.indexPatterns, datasourceMap, indexPatterns]);
const { TopNavMenu } = navigation.ui;
const { from, to } = data.query.timefilter.timefilter.getTime();
@ -190,7 +225,7 @@ export const LensTopNavMenu = ({
if (datatable) {
const postFix = datatables.length > 1 ? `-${i + 1}` : '';
memo[`${lastKnownDoc?.title || unsavedTitle}${postFix}.csv`] = {
memo[`${title || unsavedTitle}${postFix}.csv`] = {
content: exporters.datatableToCSV(datatable, {
csvSeparator: uiSettings.get('csv:separator', ','),
quoteValues: uiSettings.get('csv:quoteValues', true),
@ -208,14 +243,14 @@ export const LensTopNavMenu = ({
}
},
saveAndReturn: () => {
if (savingToDashboardPermitted && lastKnownDoc) {
if (savingToDashboardPermitted) {
// disabling the validation on app leave because the document has been saved.
onAppLeave((actions) => {
return actions.default();
});
runSave(
{
newTitle: lastKnownDoc.title,
newTitle: title || '',
newCopyOnSave: false,
isTitleDuplicateConfirmed: false,
returnToOrigin: true,
@ -248,7 +283,7 @@ export const LensTopNavMenu = ({
initialInput,
isLinkedToOriginatingApp,
isSaveable,
lastKnownDoc,
title,
onAppLeave,
redirectToOrigin,
runSave,
@ -321,7 +356,7 @@ export const LensTopNavMenu = ({
onSaved={onSavedWrapped}
onSavedQueryUpdated={onSavedQueryUpdatedWrapped}
onClearSavedQuery={onClearSavedQueryWrapped}
indexPatterns={indexPatternsForTopNav}
indexPatterns={indexPatterns}
query={query}
dateRangeFrom={from}
dateRangeTo={to}

View file

@ -4,45 +4,150 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { makeDefaultServices, mockLensStore } from '../mocks';
import { makeDefaultServices, makeLensStore, defaultDoc, createMockVisualization } from '../mocks';
import { createMockDatasource, DatasourceMock } from '../mocks';
import { act } from 'react-dom/test-utils';
import { loadDocument } from './mounter';
import { loadInitialStore } from './mounter';
import { LensEmbeddableInput } from '../embeddable/embeddable';
const defaultSavedObjectId = '1234';
const preloadedState = {
isLoading: true,
visualization: {
state: null,
activeId: 'testVis',
},
};
describe('Mounter', () => {
const byValueFlag = { allowByValueEmbeddables: true };
describe('loadDocument', () => {
const mockDatasource: DatasourceMock = createMockDatasource('testDatasource');
const mockDatasource2: DatasourceMock = createMockDatasource('testDatasource2');
const datasourceMap = {
testDatasource2: mockDatasource2,
testDatasource: mockDatasource,
};
const mockVisualization = {
...createMockVisualization(),
id: 'testVis',
visualizationTypes: [
{
icon: 'empty',
id: 'testVis',
label: 'TEST1',
groupLabel: 'testVisGroup',
},
],
};
const mockVisualization2 = {
...createMockVisualization(),
id: 'testVis2',
visualizationTypes: [
{
icon: 'empty',
id: 'testVis2',
label: 'TEST2',
groupLabel: 'testVis2Group',
},
],
};
const visualizationMap = {
testVis: mockVisualization,
testVis2: mockVisualization2,
};
it('should initialize initial datasource', async () => {
const services = makeDefaultServices();
const redirectCallback = jest.fn();
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc);
const lensStore = await makeLensStore({
data: services.data,
preloadedState,
});
await act(async () => {
await loadInitialStore(
redirectCallback,
undefined,
services,
lensStore,
undefined,
byValueFlag,
datasourceMap,
visualizationMap
);
});
expect(mockDatasource.initialize).toHaveBeenCalled();
});
it('should have initialized only the initial datasource and visualization', async () => {
const services = makeDefaultServices();
const redirectCallback = jest.fn();
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc);
const lensStore = await makeLensStore({ data: services.data, preloadedState });
await act(async () => {
await loadInitialStore(
redirectCallback,
undefined,
services,
lensStore,
undefined,
byValueFlag,
datasourceMap,
visualizationMap
);
});
expect(mockDatasource.initialize).toHaveBeenCalled();
expect(mockDatasource2.initialize).not.toHaveBeenCalled();
expect(mockVisualization.initialize).toHaveBeenCalled();
expect(mockVisualization2.initialize).not.toHaveBeenCalled();
});
// it('should initialize all datasources with state from doc', async () => {})
// it('should pass the datasource api for each layer to the visualization', async () => {})
// it('should create a separate datasource public api for each layer', async () => {})
// it('should not initialize visualization before datasource is initialized', async () => {})
// it('should pass the public frame api into visualization initialize', async () => {})
// it('should fetch suggestions of currently active datasource when initializes from visualization trigger', async () => {})
// it.skip('should pass the datasource api for each layer to the visualization', async () => {})
// it('displays errors from the frame in a toast', async () => {
describe('loadInitialStore', () => {
it('does not load a document if there is no initial input', async () => {
const services = makeDefaultServices();
const redirectCallback = jest.fn();
const lensStore = mockLensStore({ data: services.data });
await loadDocument(redirectCallback, undefined, services, lensStore, undefined, byValueFlag);
const lensStore = makeLensStore({ data: services.data, preloadedState });
await loadInitialStore(
redirectCallback,
undefined,
services,
lensStore,
undefined,
byValueFlag,
datasourceMap,
visualizationMap
);
expect(services.attributeService.unwrapAttributes).not.toHaveBeenCalled();
});
it('loads a document and uses query and filters if initial input is provided', async () => {
const services = makeDefaultServices();
const redirectCallback = jest.fn();
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
savedObjectId: defaultSavedObjectId,
state: {
query: 'fake query',
filters: [{ query: { match_phrase: { src: 'test' } } }],
},
references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }],
});
services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc);
const lensStore = await mockLensStore({ data: services.data });
const lensStore = await makeLensStore({ data: services.data, preloadedState });
await act(async () => {
await loadDocument(
await loadInitialStore(
redirectCallback,
{ savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
services,
lensStore,
undefined,
byValueFlag
byValueFlag,
datasourceMap,
visualizationMap
);
});
@ -50,21 +155,16 @@ describe('Mounter', () => {
savedObjectId: defaultSavedObjectId,
});
expect(services.data.indexPatterns.get).toHaveBeenCalledWith('1');
expect(services.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([
{ query: { match_phrase: { src: 'test' } } },
]);
expect(lensStore.getState()).toEqual({
app: expect.objectContaining({
persistedDoc: expect.objectContaining({
savedObjectId: defaultSavedObjectId,
state: expect.objectContaining({
query: 'fake query',
filters: [{ query: { match_phrase: { src: 'test' } } }],
}),
}),
lens: expect.objectContaining({
persistedDoc: { ...defaultDoc, type: 'lens' },
query: 'kuery',
isLoading: false,
activeDatasourceId: 'testDatasource',
}),
});
});
@ -72,40 +172,46 @@ describe('Mounter', () => {
it('does not load documents on sequential renders unless the id changes', async () => {
const redirectCallback = jest.fn();
const services = makeDefaultServices();
const lensStore = mockLensStore({ data: services.data });
const lensStore = makeLensStore({ data: services.data, preloadedState });
await act(async () => {
await loadDocument(
await loadInitialStore(
redirectCallback,
{ savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
services,
lensStore,
undefined,
byValueFlag
byValueFlag,
datasourceMap,
visualizationMap
);
});
await act(async () => {
await loadDocument(
await loadInitialStore(
redirectCallback,
{ savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
services,
lensStore,
undefined,
byValueFlag
byValueFlag,
datasourceMap,
visualizationMap
);
});
expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(1);
await act(async () => {
await loadDocument(
await loadInitialStore(
redirectCallback,
{ savedObjectId: '5678' } as LensEmbeddableInput,
services,
lensStore,
undefined,
byValueFlag
byValueFlag,
datasourceMap,
visualizationMap
);
});
@ -116,18 +222,20 @@ describe('Mounter', () => {
const services = makeDefaultServices();
const redirectCallback = jest.fn();
const lensStore = mockLensStore({ data: services.data });
const lensStore = makeLensStore({ data: services.data, preloadedState });
services.attributeService.unwrapAttributes = jest.fn().mockRejectedValue('failed to load');
await act(async () => {
await loadDocument(
await loadInitialStore(
redirectCallback,
{ savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
services,
lensStore,
undefined,
byValueFlag
byValueFlag,
datasourceMap,
visualizationMap
);
});
expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
@ -141,15 +249,17 @@ describe('Mounter', () => {
const redirectCallback = jest.fn();
const services = makeDefaultServices();
const lensStore = mockLensStore({ data: services.data });
const lensStore = makeLensStore({ data: services.data, preloadedState });
await act(async () => {
await loadDocument(
await loadInitialStore(
redirectCallback,
({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput,
services,
lensStore,
undefined,
byValueFlag
byValueFlag,
datasourceMap,
visualizationMap
);
});

View file

@ -23,7 +23,7 @@ import { Storage } from '../../../../../src/plugins/kibana_utils/public';
import { LensReportManager, setReportManager, trackUiEvent } from '../lens_ui_telemetry';
import { App } from './app';
import { EditorFrameStart } from '../types';
import { Datasource, EditorFrameStart, Visualization } from '../types';
import { addHelpMenuToAppChrome } from '../help_menu_util';
import { LensPluginStartDependencies } from '../plugin';
import { LENS_EMBEDDABLE_TYPE, LENS_EDIT_BY_VALUE, APP_ID } from '../../common';
@ -32,7 +32,10 @@ import {
LensByReferenceInput,
LensByValueInput,
} from '../embeddable/embeddable';
import { ACTION_VISUALIZE_LENS_FIELD } from '../../../../../src/plugins/ui_actions/public';
import {
ACTION_VISUALIZE_LENS_FIELD,
VisualizeFieldContext,
} from '../../../../../src/plugins/ui_actions/public';
import { LensAttributeService } from '../lens_attribute_service';
import { LensAppServices, RedirectToOriginProps, HistoryLocationState } from './types';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
@ -43,9 +46,18 @@ import {
getPreloadedState,
LensRootStore,
setState,
LensAppState,
updateLayer,
updateVisualizationState,
} from '../state_management';
import { getResolvedDateRange } from '../utils';
import { getLastKnownDoc } from './save_modal_container';
import { getPersistedDoc } from './save_modal_container';
import { getResolvedDateRange, getInitialDatasourceId } from '../utils';
import { initializeDatasources } from '../editor_frame_service/editor_frame';
import { generateId } from '../id_generator';
import {
getVisualizeFieldSuggestions,
switchToSuggestion,
} from '../editor_frame_service/editor_frame/suggestion_helpers';
export async function getLensServices(
coreStart: CoreStart,
@ -166,7 +178,19 @@ export async function mountApp(
if (!initialContext) {
data.query.filterManager.setAppFilters([]);
}
const { datasourceMap, visualizationMap } = instance;
const initialDatasourceId = getInitialDatasourceId(datasourceMap);
const datasourceStates: LensAppState['datasourceStates'] = {};
if (initialDatasourceId) {
datasourceStates[initialDatasourceId] = {
state: null,
isLoading: true,
};
}
const preloadedState = getPreloadedState({
isLoading: true,
query: data.query.queryString.getQuery(),
// Do not use app-specific filters from previous app,
// only if Lens was opened with the intention to visualize a field (e.g. coming from Discover)
@ -176,10 +200,15 @@ export async function mountApp(
searchSessionId: data.search.session.getSessionId(),
resolvedDateRange: getResolvedDateRange(data.query.timefilter.timefilter),
isLinkedToOriginatingApp: Boolean(embeddableEditorIncomingState?.originatingApp),
activeDatasourceId: initialDatasourceId,
datasourceStates,
visualization: {
state: null,
activeId: Object.keys(visualizationMap)[0] || null,
},
});
const lensStore: LensRootStore = makeConfigureStore(preloadedState, { data });
const EditorRenderer = React.memo(
(props: { id?: string; history: History<unknown>; editByValue?: boolean }) => {
const redirectCallback = useCallback(
@ -190,14 +219,18 @@ export async function mountApp(
);
trackUiEvent('loaded');
const initialInput = getInitialInput(props.id, props.editByValue);
loadDocument(
loadInitialStore(
redirectCallback,
initialInput,
lensServices,
lensStore,
embeddableEditorIncomingState,
dashboardFeatureFlag
dashboardFeatureFlag,
datasourceMap,
visualizationMap,
initialContext
);
return (
<Provider store={lensStore}>
<App
@ -209,7 +242,8 @@ export async function mountApp(
onAppLeave={params.onAppLeave}
setHeaderActionMenu={params.setHeaderActionMenu}
history={props.history}
initialContext={initialContext}
datasourceMap={datasourceMap}
visualizationMap={visualizationMap}
/>
</Provider>
);
@ -270,64 +304,180 @@ export async function mountApp(
};
}
export function loadDocument(
export function loadInitialStore(
redirectCallback: (savedObjectId?: string) => void,
initialInput: LensEmbeddableInput | undefined,
lensServices: LensAppServices,
lensStore: LensRootStore,
embeddableEditorIncomingState: EmbeddableEditorState | undefined,
dashboardFeatureFlag: DashboardFeatureFlagConfig
dashboardFeatureFlag: DashboardFeatureFlagConfig,
datasourceMap: Record<string, Datasource>,
visualizationMap: Record<string, Visualization>,
initialContext?: VisualizeFieldContext
) {
const { attributeService, chrome, notifications, data } = lensServices;
const { persistedDoc } = lensStore.getState().app;
const { persistedDoc } = lensStore.getState().lens;
if (
!initialInput ||
(attributeService.inputIsRefType(initialInput) &&
initialInput.savedObjectId === persistedDoc?.savedObjectId)
) {
return;
}
lensStore.dispatch(setState({ isAppLoading: true }));
return initializeDatasources(
datasourceMap,
lensStore.getState().lens.datasourceStates,
undefined,
initialContext,
{
isFullEditor: true,
}
)
.then((result) => {
const datasourceStates = Object.entries(result).reduce(
(state, [datasourceId, datasourceState]) => ({
...state,
[datasourceId]: {
...datasourceState,
isLoading: false,
},
}),
{}
);
lensStore.dispatch(
setState({
datasourceStates,
isLoading: false,
})
);
if (initialContext) {
const selectedSuggestion = getVisualizeFieldSuggestions({
datasourceMap,
datasourceStates,
visualizationMap,
activeVisualizationId: Object.keys(visualizationMap)[0] || null,
visualizationState: null,
visualizeTriggerFieldContext: initialContext,
});
if (selectedSuggestion) {
switchToSuggestion(lensStore.dispatch, selectedSuggestion, 'SWITCH_VISUALIZATION');
}
}
const activeDatasourceId = getInitialDatasourceId(datasourceMap);
const visualization = lensStore.getState().lens.visualization;
const activeVisualization =
visualization.activeId && visualizationMap[visualization.activeId];
getLastKnownDoc({
if (visualization.state === null && activeVisualization) {
const newLayerId = generateId();
const initialVisualizationState = activeVisualization.initialize(() => newLayerId);
lensStore.dispatch(
updateLayer({
datasourceId: activeDatasourceId!,
layerId: newLayerId,
updater: datasourceMap[activeDatasourceId!].insertLayer,
})
);
lensStore.dispatch(
updateVisualizationState({
visualizationId: activeVisualization.id,
updater: initialVisualizationState,
})
);
}
})
.catch((e: { message: string }) => {
notifications.toasts.addDanger({
title: e.message,
});
redirectCallback();
});
}
getPersistedDoc({
initialInput,
attributeService,
data,
chrome,
notifications,
}).then(
(newState) => {
if (newState) {
const { doc, indexPatterns } = newState;
const currentSessionId = data.search.session.getSessionId();
})
.then(
(doc) => {
if (doc) {
const currentSessionId = data.search.session.getSessionId();
const docDatasourceStates = Object.entries(doc.state.datasourceStates).reduce(
(stateMap, [datasourceId, datasourceState]) => ({
...stateMap,
[datasourceId]: {
isLoading: true,
state: datasourceState,
},
}),
{}
);
initializeDatasources(
datasourceMap,
docDatasourceStates,
doc.references,
initialContext,
{
isFullEditor: true,
}
)
.then((result) => {
const activeDatasourceId = getInitialDatasourceId(datasourceMap, doc);
lensStore.dispatch(
setState({
query: doc.state.query,
searchSessionId:
dashboardFeatureFlag.allowByValueEmbeddables &&
Boolean(embeddableEditorIncomingState?.originatingApp) &&
!(initialInput as LensByReferenceInput)?.savedObjectId &&
currentSessionId
? currentSessionId
: data.search.session.start(),
...(!isEqual(persistedDoc, doc) ? { persistedDoc: doc } : null),
activeDatasourceId,
visualization: {
activeId: doc.visualizationType,
state: doc.state.visualization,
},
datasourceStates: Object.entries(result).reduce(
(state, [datasourceId, datasourceState]) => ({
...state,
[datasourceId]: {
...datasourceState,
isLoading: false,
},
}),
{}
),
isLoading: false,
})
);
})
.catch((e: { message: string }) =>
notifications.toasts.addDanger({
title: e.message,
})
);
} else {
redirectCallback();
}
},
() => {
lensStore.dispatch(
setState({
query: doc.state.query,
isAppLoading: false,
indexPatternsForTopNav: indexPatterns,
lastKnownDoc: doc,
searchSessionId:
dashboardFeatureFlag.allowByValueEmbeddables &&
Boolean(embeddableEditorIncomingState?.originatingApp) &&
!(initialInput as LensByReferenceInput)?.savedObjectId &&
currentSessionId
? currentSessionId
: data.search.session.start(),
...(!isEqual(persistedDoc, doc) ? { persistedDoc: doc } : null),
isLoading: false,
})
);
} else {
redirectCallback();
}
},
() => {
lensStore.dispatch(
setState({
isAppLoading: false,
})
);
redirectCallback();
}
);
)
.catch((e: { message: string }) =>
notifications.toasts.addDanger({
title: e.message,
})
);
}

View file

@ -7,8 +7,6 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { Document } from '../persistence';
import type { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public';
import {
@ -23,7 +21,6 @@ import {
export type SaveProps = OriginSaveProps | DashboardSaveProps;
export interface Props {
isVisible: boolean;
savingToLibraryPermitted?: boolean;
originatingApp?: string;
@ -32,7 +29,9 @@ export interface Props {
savedObjectsTagging?: SavedObjectTaggingPluginStart;
tagsIds: string[];
lastKnownDoc?: Document;
title?: string;
savedObjectId?: string;
description?: string;
getAppNameFromId: () => string | undefined;
returnToOriginSwitchLabel?: string;
@ -42,16 +41,14 @@ export interface Props {
}
export const SaveModal = (props: Props) => {
if (!props.isVisible || !props.lastKnownDoc) {
return null;
}
const {
originatingApp,
savingToLibraryPermitted,
savedObjectsTagging,
tagsIds,
lastKnownDoc,
savedObjectId,
title,
description,
allowByValueEmbeddables,
returnToOriginSwitchLabel,
getAppNameFromId,
@ -70,9 +67,9 @@ export const SaveModal = (props: Props) => {
onSave={(saveProps) => onSave(saveProps, { saveToLibrary: true })}
getAppNameFromId={getAppNameFromId}
documentInfo={{
id: lastKnownDoc.savedObjectId,
title: lastKnownDoc.title || '',
description: lastKnownDoc.description || '',
id: savedObjectId,
title: title || '',
description: description || '',
}}
returnToOriginSwitchLabel={returnToOriginSwitchLabel}
objectType={i18n.translate('xpack.lens.app.saveModalType', {
@ -95,9 +92,9 @@ export const SaveModal = (props: Props) => {
onClose={onClose}
documentInfo={{
// if the user cannot save to the library - treat this as a new document.
id: savingToLibraryPermitted ? lastKnownDoc.savedObjectId : undefined,
title: lastKnownDoc.title || '',
description: lastKnownDoc.description || '',
id: savingToLibraryPermitted ? savedObjectId : undefined,
title: title || '',
description: description || '',
}}
objectType={i18n.translate('xpack.lens.app.saveModalType', {
defaultMessage: 'Lens visualization',

View file

@ -8,21 +8,16 @@
import React, { useEffect, useState } from 'react';
import { ChromeStart, NotificationsStart } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { partition, uniq } from 'lodash';
import { METRIC_TYPE } from '@kbn/analytics';
import { partition } from 'lodash';
import { SaveModal } from './save_modal';
import { LensAppProps, LensAppServices } from './types';
import type { SaveProps } from './app';
import { Document, injectFilterReferences } from '../persistence';
import { LensByReferenceInput, LensEmbeddableInput } from '../embeddable';
import { LensAttributeService } from '../lens_attribute_service';
import {
DataPublicPluginStart,
esFilters,
IndexPattern,
} from '../../../../../src/plugins/data/public';
import { DataPublicPluginStart, esFilters } from '../../../../../src/plugins/data/public';
import { APP_ID, getFullPath, LENS_EMBEDDABLE_TYPE } from '../../common';
import { getAllIndexPatterns } from '../utils';
import { trackUiEvent } from '../lens_ui_telemetry';
import { checkForDuplicateTitle } from '../../../../../src/plugins/saved_objects/public';
import { LensAppState } from '../state_management';
@ -31,7 +26,6 @@ type ExtraProps = Pick<LensAppProps, 'initialInput'> &
Partial<Pick<LensAppProps, 'redirectToOrigin' | 'redirectTo' | 'onAppLeave'>>;
export type SaveModalContainerProps = {
isVisible: boolean;
originatingApp?: string;
persistedDoc?: Document;
lastKnownDoc?: Document;
@ -49,7 +43,6 @@ export function SaveModalContainer({
onClose,
onSave,
runSave,
isVisible,
persistedDoc,
originatingApp,
initialInput,
@ -61,6 +54,14 @@ export function SaveModalContainer({
lensServices,
}: SaveModalContainerProps) {
const [lastKnownDoc, setLastKnownDoc] = useState<Document | undefined>(initLastKnowDoc);
let title = '';
let description;
let savedObjectId;
if (lastKnownDoc) {
title = lastKnownDoc.title;
description = lastKnownDoc.description;
savedObjectId = lastKnownDoc.savedObjectId;
}
const {
attributeService,
@ -77,22 +78,26 @@ export function SaveModalContainer({
}, [initLastKnowDoc]);
useEffect(() => {
async function loadLastKnownDoc() {
if (initialInput && isVisible) {
getLastKnownDoc({
let isMounted = true;
async function loadPersistedDoc() {
if (initialInput) {
getPersistedDoc({
data,
initialInput,
chrome,
notifications,
attributeService,
}).then((result) => {
if (result) setLastKnownDoc(result.doc);
}).then((doc) => {
if (doc && isMounted) setLastKnownDoc(doc);
});
}
}
loadLastKnownDoc();
}, [chrome, data, initialInput, notifications, attributeService, isVisible]);
loadPersistedDoc();
return () => {
isMounted = false;
};
}, [chrome, data, initialInput, notifications, attributeService]);
const tagsIds =
persistedDoc && savedObjectsTagging
@ -131,7 +136,6 @@ export function SaveModalContainer({
return (
<SaveModal
isVisible={isVisible}
originatingApp={originatingApp}
savingToLibraryPermitted={savingToLibraryPermitted}
allowByValueEmbeddables={dashboardFeatureFlag?.allowByValueEmbeddables}
@ -142,7 +146,9 @@ export function SaveModalContainer({
}}
onClose={onClose}
getAppNameFromId={getAppNameFromId}
lastKnownDoc={lastKnownDoc}
title={title}
description={description}
savedObjectId={savedObjectId}
returnToOriginSwitchLabel={returnToOriginSwitchLabel}
/>
);
@ -330,7 +336,10 @@ export const runSaveLensVisualization = async (
...newInput,
};
return { persistedDoc: newDoc, lastKnownDoc: newDoc, isLinkedToOriginatingApp: false };
return {
persistedDoc: newDoc,
isLinkedToOriginatingApp: false,
};
} catch (e) {
// eslint-disable-next-line no-console
console.dir(e);
@ -356,7 +365,7 @@ export function getLastKnownDocWithoutPinnedFilters(doc?: Document) {
: doc;
}
export const getLastKnownDoc = async ({
export const getPersistedDoc = async ({
initialInput,
attributeService,
data,
@ -368,7 +377,7 @@ export const getLastKnownDoc = async ({
data: DataPublicPluginStart;
notifications: NotificationsStart;
chrome: ChromeStart;
}): Promise<{ doc: Document; indexPatterns: IndexPattern[] } | undefined> => {
}): Promise<Document | undefined> => {
let doc: Document;
try {
@ -387,19 +396,12 @@ export const getLastKnownDoc = async ({
initialInput.savedObjectId
);
}
const indexPatternIds = uniq(
doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)
);
const { indexPatterns } = await getAllIndexPatterns(indexPatternIds, data.indexPatterns);
// Don't overwrite any pinned filters
data.query.filterManager.setAppFilters(
injectFilterReferences(doc.state.filters, doc.references)
);
return {
doc,
indexPatterns,
};
return doc;
} catch (e) {
notifications.toasts.addDanger(
i18n.translate('xpack.lens.app.docLoadingError', {

View file

@ -34,7 +34,7 @@ import {
EmbeddableEditorState,
EmbeddableStateTransfer,
} from '../../../../../src/plugins/embeddable/public';
import { EditorFrameInstance } from '../types';
import { Datasource, EditorFrameInstance, Visualization } from '../types';
import { PresentationUtilPluginStart } from '../../../../../src/plugins/presentation_util/public';
export interface RedirectToOriginProps {
input?: LensEmbeddableInput;
@ -54,7 +54,8 @@ export interface LensAppProps {
// State passed in by the container which is used to determine the id of the Originating App.
incomingState?: EmbeddableEditorState;
initialContext?: VisualizeFieldContext;
datasourceMap: Record<string, Datasource>;
visualizationMap: Record<string, Visualization>;
}
export type RunSave = (
@ -81,6 +82,8 @@ export interface LensTopNavMenuProps {
indicateNoData: boolean;
setIsSaveModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
runSave: RunSave;
datasourceMap: Record<string, Datasource>;
title?: string;
}
export interface HistoryLocationState {

View file

@ -9,7 +9,7 @@ import React from 'react';
import { EuiButtonGroup, EuiComboBox, EuiFieldText } from '@elastic/eui';
import { FramePublicAPI, Operation, VisualizationDimensionEditorProps } from '../../types';
import { DatatableVisualizationState } from '../visualization';
import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks';
import { createMockDatasource, createMockFramePublicAPI } from '../../mocks';
import { mountWithIntl } from '@kbn/test/jest';
import { TableDimensionEditor } from './dimension_editor';
import { chartPluginMock } from 'src/plugins/charts/public/mocks';

View file

@ -7,7 +7,7 @@
import { Ast } from '@kbn/interpreter/common';
import { buildExpression } from '../../../../../src/plugins/expressions/public';
import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks';
import { createMockDatasource, createMockFramePublicAPI } from '../mocks';
import { DatatableVisualizationState, getDatatableVisualization } from './visualization';
import {
Operation,
@ -21,8 +21,6 @@ import { chartPluginMock } from 'src/plugins/charts/public/mocks';
function mockFrame(): FramePublicAPI {
return {
...createMockFramePublicAPI(),
addNewLayer: () => 'aaa',
removeLayers: () => {},
datasourceLayers: {},
query: { query: '', language: 'lucene' },
dateRange: {
@ -40,7 +38,7 @@ const datatableVisualization = getDatatableVisualization({
describe('Datatable Visualization', () => {
describe('#initialize', () => {
it('should initialize from the empty state', () => {
expect(datatableVisualization.initialize(mockFrame(), undefined)).toEqual({
expect(datatableVisualization.initialize(() => 'aaa', undefined)).toEqual({
layerId: 'aaa',
columns: [],
});
@ -51,7 +49,7 @@ describe('Datatable Visualization', () => {
layerId: 'foo',
columns: [{ columnId: 'saved' }],
};
expect(datatableVisualization.initialize(mockFrame(), expectedState)).toEqual(expectedState);
expect(datatableVisualization.initialize(() => 'foo', expectedState)).toEqual(expectedState);
});
});

View file

@ -101,11 +101,11 @@ export const getDatatableVisualization = ({
switchVisualizationType: (_, state) => state,
initialize(frame, state) {
initialize(addNewLayer, state) {
return (
state || {
columns: [],
layerId: frame.addNewLayer(),
layerId: addNewLayer(),
}
);
},

View file

@ -12,13 +12,13 @@ import {
createMockFramePublicAPI,
createMockDatasource,
DatasourceMock,
} from '../../mocks';
} from '../../../mocks';
import { Visualization } from '../../../types';
import { mountWithIntl } from '@kbn/test/jest';
import { LayerPanels } from './config_panel';
import { LayerPanel } from './layer_panel';
import { coreMock } from 'src/core/public/mocks';
import { generateId } from '../../../id_generator';
import { mountWithProvider } from '../../../mocks';
jest.mock('../../../id_generator');
@ -54,17 +54,17 @@ describe('ConfigPanel', () => {
vis1: mockVisualization,
vis2: mockVisualization2,
},
activeDatasourceId: 'ds1',
activeDatasourceId: 'mockindexpattern',
datasourceMap: {
ds1: mockDatasource,
mockindexpattern: mockDatasource,
},
activeVisualization: ({
...mockVisualization,
getLayerIds: () => Object.keys(frame.datasourceLayers),
appendLayer: true,
appendLayer: jest.fn(),
} as unknown) as Visualization,
datasourceStates: {
ds1: {
mockindexpattern: {
isLoading: false,
state: 'state',
},
@ -110,113 +110,184 @@ describe('ConfigPanel', () => {
};
mockVisualization.getLayerIds.mockReturnValue(Object.keys(frame.datasourceLayers));
mockDatasource = createMockDatasource('ds1');
mockDatasource = createMockDatasource('mockindexpattern');
});
// in what case is this test needed?
it('should fail to render layerPanels if the public API is out of date', () => {
it('should fail to render layerPanels if the public API is out of date', async () => {
const props = getDefaultProps();
props.framePublicAPI.datasourceLayers = {};
const component = mountWithIntl(<LayerPanels {...props} />);
expect(component.find(LayerPanel).exists()).toBe(false);
const { instance } = await mountWithProvider(<LayerPanels {...props} />);
expect(instance.find(LayerPanel).exists()).toBe(false);
});
it('allow datasources and visualizations to use setters', async () => {
const props = getDefaultProps();
const component = mountWithIntl(<LayerPanels {...props} />);
const { updateDatasource, updateAll } = component.find(LayerPanel).props();
const { instance, lensStore } = await mountWithProvider(<LayerPanels {...props} />, {
preloadedState: {
datasourceStates: {
mockindexpattern: {
isLoading: false,
state: 'state',
},
},
},
});
const { updateDatasource, updateAll } = instance.find(LayerPanel).props();
const updater = () => 'updated';
updateDatasource('ds1', updater);
// wait for one tick so async updater has a chance to trigger
updateDatasource('mockindexpattern', updater);
await new Promise((r) => setTimeout(r, 0));
expect(props.dispatch).toHaveBeenCalledTimes(1);
expect(props.dispatch.mock.calls[0][0].updater(props.datasourceStates.ds1.state)).toEqual(
'updated'
);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(
(lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater(
props.datasourceStates.mockindexpattern.state
)
).toEqual('updated');
updateAll('ds1', updater, props.visualizationState);
updateAll('mockindexpattern', updater, props.visualizationState);
// wait for one tick so async updater has a chance to trigger
await new Promise((r) => setTimeout(r, 0));
expect(props.dispatch).toHaveBeenCalledTimes(2);
expect(props.dispatch.mock.calls[0][0].updater(props.datasourceStates.ds1.state)).toEqual(
'updated'
);
expect(lensStore.dispatch).toHaveBeenCalledTimes(2);
expect(
(lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater(
props.datasourceStates.mockindexpattern.state
)
).toEqual('updated');
});
describe('focus behavior when adding or removing layers', () => {
it('should focus the only layer when resetting the layer', () => {
const component = mountWithIntl(<LayerPanels {...getDefaultProps()} />, {
attachTo: container,
});
const firstLayerFocusable = component
it('should focus the only layer when resetting the layer', async () => {
const { instance } = await mountWithProvider(
<LayerPanels {...getDefaultProps()} />,
{
preloadedState: {
datasourceStates: {
mockindexpattern: {
isLoading: false,
state: 'state',
},
},
},
},
{
attachTo: container,
}
);
const firstLayerFocusable = instance
.find(LayerPanel)
.first()
.find('section')
.first()
.instance();
act(() => {
component.find('[data-test-subj="lnsLayerRemove"]').first().simulate('click');
instance.find('[data-test-subj="lnsLayerRemove"]').first().simulate('click');
});
const focusedEl = document.activeElement;
expect(focusedEl).toEqual(firstLayerFocusable);
});
it('should focus the second layer when removing the first layer', () => {
it('should focus the second layer when removing the first layer', async () => {
const defaultProps = getDefaultProps();
// overwriting datasourceLayers to test two layers
frame.datasourceLayers = {
first: mockDatasource.publicAPIMock,
second: mockDatasource.publicAPIMock,
};
const component = mountWithIntl(<LayerPanels {...defaultProps} />, { attachTo: container });
const secondLayerFocusable = component
const { instance } = await mountWithProvider(
<LayerPanels {...defaultProps} />,
{
preloadedState: {
datasourceStates: {
mockindexpattern: {
isLoading: false,
state: 'state',
},
},
},
},
{
attachTo: container,
}
);
const secondLayerFocusable = instance
.find(LayerPanel)
.at(1)
.find('section')
.first()
.instance();
act(() => {
component.find('[data-test-subj="lnsLayerRemove"]').at(0).simulate('click');
instance.find('[data-test-subj="lnsLayerRemove"]').at(0).simulate('click');
});
const focusedEl = document.activeElement;
expect(focusedEl).toEqual(secondLayerFocusable);
});
it('should focus the first layer when removing the second layer', () => {
it('should focus the first layer when removing the second layer', async () => {
const defaultProps = getDefaultProps();
// overwriting datasourceLayers to test two layers
frame.datasourceLayers = {
first: mockDatasource.publicAPIMock,
second: mockDatasource.publicAPIMock,
};
const component = mountWithIntl(<LayerPanels {...defaultProps} />, { attachTo: container });
const firstLayerFocusable = component
const { instance } = await mountWithProvider(
<LayerPanels {...defaultProps} />,
{
preloadedState: {
datasourceStates: {
mockindexpattern: {
isLoading: false,
state: 'state',
},
},
},
},
{
attachTo: container,
}
);
const firstLayerFocusable = instance
.find(LayerPanel)
.first()
.find('section')
.first()
.instance();
act(() => {
component.find('[data-test-subj="lnsLayerRemove"]').at(2).simulate('click');
instance.find('[data-test-subj="lnsLayerRemove"]').at(2).simulate('click');
});
const focusedEl = document.activeElement;
expect(focusedEl).toEqual(firstLayerFocusable);
});
it('should focus the added layer', () => {
it('should focus the added layer', async () => {
(generateId as jest.Mock).mockReturnValue(`second`);
const dispatch = jest.fn((x) => {
if (x.subType === 'ADD_LAYER') {
frame.datasourceLayers.second = mockDatasource.publicAPIMock;
}
});
const component = mountWithIntl(<LayerPanels {...getDefaultProps()} dispatch={dispatch} />, {
attachTo: container,
});
const { instance } = await mountWithProvider(
<LayerPanels {...getDefaultProps()} />,
{
preloadedState: {
datasourceStates: {
mockindexpattern: {
isLoading: false,
state: 'state',
},
},
activeDatasourceId: 'mockindexpattern',
},
dispatch: jest.fn((x) => {
if (x.payload.subType === 'ADD_LAYER') {
frame.datasourceLayers.second = mockDatasource.publicAPIMock;
}
}),
},
{
attachTo: container,
}
);
act(() => {
component.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click');
instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click');
});
const focusedEl = document.activeElement;
expect(focusedEl?.children[0].getAttribute('data-test-subj')).toEqual('lns-layerPanel-1');

View file

@ -10,13 +10,21 @@ import './config_panel.scss';
import React, { useMemo, memo } from 'react';
import { EuiFlexItem, EuiToolTip, EuiButton, EuiForm } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { mapValues } from 'lodash';
import { Visualization } from '../../../types';
import { LayerPanel } from './layer_panel';
import { trackUiEvent } from '../../../lens_ui_telemetry';
import { generateId } from '../../../id_generator';
import { removeLayer, appendLayer } from './layer_actions';
import { appendLayer } from './layer_actions';
import { ConfigPanelWrapperProps } from './types';
import { useFocusUpdate } from './use_focus_update';
import {
useLensDispatch,
updateState,
updateDatasourceState,
updateVisualizationState,
setToggleFullscreen,
} from '../../../state_management';
export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) {
const activeVisualization = props.visualizationMap[props.activeVisualizationId || ''];
@ -33,13 +41,8 @@ export function LayerPanels(
activeVisualization: Visualization;
}
) {
const {
activeVisualization,
visualizationState,
dispatch,
activeDatasourceId,
datasourceMap,
} = props;
const { activeVisualization, visualizationState, activeDatasourceId, datasourceMap } = props;
const dispatchLens = useLensDispatch();
const layerIds = activeVisualization.getLayerIds(visualizationState);
const {
@ -50,26 +53,28 @@ export function LayerPanels(
const setVisualizationState = useMemo(
() => (newState: unknown) => {
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
updater: newState,
clearStagedPreview: false,
});
dispatchLens(
updateVisualizationState({
visualizationId: activeVisualization.id,
updater: newState,
clearStagedPreview: false,
})
);
},
[dispatch, activeVisualization]
[activeVisualization, dispatchLens]
);
const updateDatasource = useMemo(
() => (datasourceId: string, newState: unknown) => {
dispatch({
type: 'UPDATE_DATASOURCE_STATE',
updater: (prevState: unknown) =>
typeof newState === 'function' ? newState(prevState) : newState,
datasourceId,
clearStagedPreview: false,
});
dispatchLens(
updateDatasourceState({
updater: (prevState: unknown) =>
typeof newState === 'function' ? newState(prevState) : newState,
datasourceId,
clearStagedPreview: false,
})
);
},
[dispatch]
[dispatchLens]
);
const updateDatasourceAsync = useMemo(
() => (datasourceId: string, newState: unknown) => {
@ -86,42 +91,42 @@ export function LayerPanels(
// React will synchronously update if this is triggered from a third party component,
// which we don't want. The timeout lets user interaction have priority, then React updates.
setTimeout(() => {
dispatch({
type: 'UPDATE_STATE',
subType: 'UPDATE_ALL_STATES',
updater: (prevState) => {
const updatedDatasourceState =
typeof newDatasourceState === 'function'
? newDatasourceState(prevState.datasourceStates[datasourceId].state)
: newDatasourceState;
return {
...prevState,
datasourceStates: {
...prevState.datasourceStates,
[datasourceId]: {
state: updatedDatasourceState,
isLoading: false,
dispatchLens(
updateState({
subType: 'UPDATE_ALL_STATES',
updater: (prevState) => {
const updatedDatasourceState =
typeof newDatasourceState === 'function'
? newDatasourceState(prevState.datasourceStates[datasourceId].state)
: newDatasourceState;
return {
...prevState,
datasourceStates: {
...prevState.datasourceStates,
[datasourceId]: {
state: updatedDatasourceState,
isLoading: false,
},
},
},
visualization: {
...prevState.visualization,
state: newVisualizationState,
},
stagedPreview: undefined,
};
},
});
visualization: {
...prevState.visualization,
state: newVisualizationState,
},
stagedPreview: undefined,
};
},
})
);
}, 0);
},
[dispatch]
[dispatchLens]
);
const toggleFullscreen = useMemo(
() => () => {
dispatch({
type: 'TOGGLE_FULLSCREEN',
});
dispatchLens(setToggleFullscreen());
},
[dispatch]
[dispatchLens]
);
const datasourcePublicAPIs = props.framePublicAPI.datasourceLayers;
@ -144,18 +149,41 @@ export function LayerPanels(
updateAll={updateAll}
isOnlyLayer={layerIds.length === 1}
onRemoveLayer={() => {
dispatch({
type: 'UPDATE_STATE',
subType: 'REMOVE_OR_CLEAR_LAYER',
updater: (state) =>
removeLayer({
activeVisualization,
layerId,
trackUiEvent,
datasourceMap,
state,
}),
});
dispatchLens(
updateState({
subType: 'REMOVE_OR_CLEAR_LAYER',
updater: (state) => {
const isOnlyLayer = activeVisualization
.getLayerIds(state.visualization.state)
.every((id) => id === layerId);
return {
...state,
datasourceStates: mapValues(
state.datasourceStates,
(datasourceState, datasourceId) => {
const datasource = datasourceMap[datasourceId!];
return {
...datasourceState,
state: isOnlyLayer
? datasource.clearLayer(datasourceState.state, layerId)
: datasource.removeLayer(datasourceState.state, layerId),
};
}
),
visualization: {
...state.visualization,
state:
isOnlyLayer || !activeVisualization.removeLayer
? activeVisualization.clearLayer(state.visualization.state, layerId)
: activeVisualization.removeLayer(state.visualization.state, layerId),
},
stagedPreview: undefined,
};
},
})
);
removeLayerRef(layerId);
}}
toggleFullscreen={toggleFullscreen}
@ -187,18 +215,19 @@ export function LayerPanels(
color="text"
onClick={() => {
const id = generateId();
dispatch({
type: 'UPDATE_STATE',
subType: 'ADD_LAYER',
updater: (state) =>
appendLayer({
activeVisualization,
generateId: () => id,
trackUiEvent,
activeDatasource: datasourceMap[activeDatasourceId],
state,
}),
});
dispatchLens(
updateState({
subType: 'ADD_LAYER',
updater: (state) =>
appendLayer({
activeVisualization,
generateId: () => id,
trackUiEvent,
activeDatasource: datasourceMap[activeDatasourceId],
state,
}),
})
);
setNextFocusedLayerId(id);
}}
iconType="plusInCircleFilled"

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { initialState } from '../../../state_management/lens_slice';
import { removeLayer, appendLayer } from './layer_actions';
function createTestArgs(initialLayerIds: string[]) {
@ -42,6 +43,7 @@ function createTestArgs(initialLayerIds: string[]) {
return {
state: {
...initialState,
activeDatasourceId: 'ds1',
datasourceStates,
title: 'foo',

View file

@ -6,12 +6,13 @@
*/
import { mapValues } from 'lodash';
import { EditorFrameState } from '../state_management';
import { LensAppState } from '../../../state_management';
import { Datasource, Visualization } from '../../../types';
interface RemoveLayerOptions {
trackUiEvent: (name: string) => void;
state: EditorFrameState;
state: LensAppState;
layerId: string;
activeVisualization: Pick<Visualization, 'getLayerIds' | 'clearLayer' | 'removeLayer'>;
datasourceMap: Record<string, Pick<Datasource, 'clearLayer' | 'removeLayer'>>;
@ -19,13 +20,13 @@ interface RemoveLayerOptions {
interface AppendLayerOptions {
trackUiEvent: (name: string) => void;
state: EditorFrameState;
state: LensAppState;
generateId: () => string;
activeDatasource: Pick<Datasource, 'insertLayer' | 'id'>;
activeVisualization: Pick<Visualization, 'appendLayer'>;
}
export function removeLayer(opts: RemoveLayerOptions): EditorFrameState {
export function removeLayer(opts: RemoveLayerOptions): LensAppState {
const { state, trackUiEvent: trackUiEvent, activeVisualization, layerId, datasourceMap } = opts;
const isOnlyLayer = activeVisualization
.getLayerIds(state.visualization.state)
@ -61,7 +62,7 @@ export function appendLayer({
state,
generateId,
activeDatasource,
}: AppendLayerOptions): EditorFrameState {
}: AppendLayerOptions): LensAppState {
trackUiEvent('layer_added');
if (!activeVisualization.appendLayer) {

View file

@ -19,7 +19,7 @@ import {
createMockFramePublicAPI,
createMockDatasource,
DatasourceMock,
} from '../../mocks';
} from '../../../mocks';
jest.mock('../../../id_generator');

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { Action } from '../state_management';
import {
Visualization,
FramePublicAPI,
@ -18,7 +17,6 @@ export interface ConfigPanelWrapperProps {
visualizationState: unknown;
visualizationMap: Record<string, Visualization>;
activeVisualizationId: string | null;
dispatch: (action: Action) => void;
framePublicAPI: FramePublicAPI;
datasourceMap: Record<string, Datasource>;
datasourceStates: Record<
@ -37,7 +35,6 @@ export interface LayerPanelProps {
visualizationState: unknown;
datasourceMap: Record<string, Datasource>;
activeVisualization: Visualization;
dispatch: (action: Action) => void;
framePublicAPI: FramePublicAPI;
datasourceStates: Record<
string,

View file

@ -7,54 +7,94 @@
import './data_panel_wrapper.scss';
import React, { useMemo, memo, useContext, useState } from 'react';
import React, { useMemo, memo, useContext, useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { createSelector } from '@reduxjs/toolkit';
import { NativeRenderer } from '../../native_renderer';
import { Action } from './state_management';
import { DragContext, DragDropIdentifier } from '../../drag_drop';
import { StateSetter, FramePublicAPI, DatasourceDataPanelProps, Datasource } from '../../types';
import { Query, Filter } from '../../../../../../src/plugins/data/public';
import { StateSetter, DatasourceDataPanelProps, Datasource } from '../../types';
import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public';
import {
switchDatasource,
useLensDispatch,
updateDatasourceState,
LensState,
useLensSelector,
setState,
} from '../../state_management';
import { initializeDatasources } from './state_helpers';
interface DataPanelWrapperProps {
datasourceState: unknown;
datasourceMap: Record<string, Datasource>;
activeDatasource: string | null;
datasourceIsLoading: boolean;
dispatch: (action: Action) => void;
showNoDataPopover: () => void;
core: DatasourceDataPanelProps['core'];
query: Query;
dateRange: FramePublicAPI['dateRange'];
filters: Filter[];
dropOntoWorkspace: (field: DragDropIdentifier) => void;
hasSuggestionForField: (field: DragDropIdentifier) => boolean;
plugins: { uiActions: UiActionsStart };
}
const getExternals = createSelector(
(state: LensState) => state.lens,
({ resolvedDateRange, query, filters, datasourceStates, activeDatasourceId }) => ({
dateRange: resolvedDateRange,
query,
filters,
datasourceStates,
activeDatasourceId,
})
);
export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
const { dispatch, activeDatasource } = props;
const setDatasourceState: StateSetter<unknown> = useMemo(
() => (updater) => {
dispatch({
type: 'UPDATE_DATASOURCE_STATE',
updater,
datasourceId: activeDatasource!,
clearStagedPreview: true,
});
},
[dispatch, activeDatasource]
const { activeDatasource } = props;
const { filters, query, dateRange, datasourceStates, activeDatasourceId } = useLensSelector(
getExternals
);
const dispatchLens = useLensDispatch();
const setDatasourceState: StateSetter<unknown> = useMemo(() => {
return (updater) => {
dispatchLens(
updateDatasourceState({
updater,
datasourceId: activeDatasource!,
clearStagedPreview: true,
})
);
};
}, [activeDatasource, dispatchLens]);
useEffect(() => {
if (activeDatasourceId && datasourceStates[activeDatasourceId].state === null) {
initializeDatasources(props.datasourceMap, datasourceStates, undefined, undefined, {
isFullEditor: true,
}).then((result) => {
const newDatasourceStates = Object.entries(result).reduce(
(state, [datasourceId, datasourceState]) => ({
...state,
[datasourceId]: {
...datasourceState,
isLoading: false,
},
}),
{}
);
dispatchLens(setState({ datasourceStates: newDatasourceStates }));
});
}
}, [datasourceStates, activeDatasourceId, props.datasourceMap, dispatchLens]);
const datasourceProps: DatasourceDataPanelProps = {
dragDropContext: useContext(DragContext),
state: props.datasourceState,
setState: setDatasourceState,
core: props.core,
query: props.query,
dateRange: props.dateRange,
filters: props.filters,
filters,
query,
dateRange,
showNoDataPopover: props.showNoDataPopover,
dropOntoWorkspace: props.dropOntoWorkspace,
hasSuggestionForField: props.hasSuggestionForField,
@ -98,10 +138,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
icon={props.activeDatasource === datasourceId ? 'check' : 'empty'}
onClick={() => {
setDatasourceSwitcher(false);
props.dispatch({
type: 'SWITCH_DATASOURCE',
newDatasourceId: datasourceId,
});
dispatchLens(switchDatasource({ newDatasourceId: datasourceId }));
}}
>
{datasourceId}

View file

@ -5,118 +5,53 @@
* 2.0.
*/
import React, { useEffect, useReducer, useState, useCallback, useRef, useMemo } from 'react';
import React, { useCallback, useRef, useMemo } from 'react';
import { CoreStart } from 'kibana/public';
import { isEqual } from 'lodash';
import { PaletteRegistry } from 'src/plugins/charts/public';
import { IndexPattern } from '../../../../../../src/plugins/data/public';
import { getAllIndexPatterns } from '../../utils';
import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public';
import { Datasource, FramePublicAPI, Visualization } from '../../types';
import { reducer, getInitialState } from './state_management';
import { DataPanelWrapper } from './data_panel_wrapper';
import { ConfigPanelWrapper } from './config_panel';
import { FrameLayout } from './frame_layout';
import { SuggestionPanel } from './suggestion_panel';
import { WorkspacePanel } from './workspace_panel';
import { Document } from '../../persistence/saved_object_store';
import { DragDropIdentifier, RootDragDropProvider } from '../../drag_drop';
import { getSavedObjectFormat } from './save';
import { generateId } from '../../id_generator';
import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public';
import { EditorFrameStartPlugins } from '../service';
import { initializeDatasources, createDatasourceLayers } from './state_helpers';
import {
applyVisualizeFieldSuggestions,
getTopSuggestionForField,
switchToSuggestion,
Suggestion,
} from './suggestion_helpers';
import { createDatasourceLayers } from './state_helpers';
import { getTopSuggestionForField, switchToSuggestion, Suggestion } from './suggestion_helpers';
import { trackUiEvent } from '../../lens_ui_telemetry';
import {
useLensSelector,
useLensDispatch,
LensAppState,
DispatchSetState,
onChangeFromEditorFrame,
} from '../../state_management';
import { useLensSelector, useLensDispatch } from '../../state_management';
export interface EditorFrameProps {
datasourceMap: Record<string, Datasource>;
visualizationMap: Record<string, Visualization>;
ExpressionRenderer: ReactExpressionRendererType;
palettes: PaletteRegistry;
onError: (e: { message: string }) => void;
core: CoreStart;
plugins: EditorFrameStartPlugins;
showNoDataPopover: () => void;
initialContext?: VisualizeFieldContext;
}
export function EditorFrame(props: EditorFrameProps) {
const {
activeData,
resolvedDateRange: dateRange,
query,
filters,
searchSessionId,
savedQuery,
query,
persistedDoc,
indexPatternsForTopNav,
lastKnownDoc,
activeData,
isSaveable,
resolvedDateRange: dateRange,
} = useLensSelector((state) => state.app);
const [state, dispatch] = useReducer(reducer, { ...props, doc: persistedDoc }, getInitialState);
activeDatasourceId,
visualization,
datasourceStates,
stagedPreview,
isFullscreenDatasource,
} = useLensSelector((state) => state.lens);
const dispatchLens = useLensDispatch();
const dispatchChange: DispatchSetState = useCallback(
(s: Partial<LensAppState>) => dispatchLens(onChangeFromEditorFrame(s)),
[dispatchLens]
);
const [visualizeTriggerFieldContext, setVisualizeTriggerFieldContext] = useState(
props.initialContext
);
const { onError } = props;
const activeVisualization =
state.visualization.activeId && props.visualizationMap[state.visualization.activeId];
const allLoaded = Object.values(state.datasourceStates).every(
({ isLoading }) => typeof isLoading === 'boolean' && !isLoading
);
const allLoaded = Object.values(datasourceStates).every(({ isLoading }) => isLoading === false);
// Initialize current datasource and all active datasources
useEffect(
() => {
// prevents executing dispatch on unmounted component
let isUnmounted = false;
if (!allLoaded) {
initializeDatasources(
props.datasourceMap,
state.datasourceStates,
persistedDoc?.references,
visualizeTriggerFieldContext,
{ isFullEditor: true }
)
.then((result) => {
if (!isUnmounted) {
Object.entries(result).forEach(([datasourceId, { state: datasourceState }]) => {
dispatch({
type: 'UPDATE_DATASOURCE_STATE',
updater: datasourceState,
datasourceId,
});
});
}
})
.catch(onError);
}
return () => {
isUnmounted = true;
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[allLoaded, onError]
const datasourceLayers = React.useMemo(
() => createDatasourceLayers(props.datasourceMap, datasourceStates),
[props.datasourceMap, datasourceStates]
);
const datasourceLayers = createDatasourceLayers(props.datasourceMap, state.datasourceStates);
const framePublicAPI: FramePublicAPI = useMemo(
() => ({
@ -126,232 +61,15 @@ export function EditorFrame(props: EditorFrameProps) {
query,
filters,
searchSessionId,
availablePalettes: props.palettes,
addNewLayer() {
const newLayerId = generateId();
dispatch({
type: 'UPDATE_LAYER',
datasourceId: state.activeDatasourceId!,
layerId: newLayerId,
updater: props.datasourceMap[state.activeDatasourceId!].insertLayer,
});
return newLayerId;
},
removeLayers(layerIds: string[]) {
if (activeVisualization && activeVisualization.removeLayer && state.visualization.state) {
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
updater: layerIds.reduce(
(acc, layerId) =>
activeVisualization.removeLayer
? activeVisualization.removeLayer(acc, layerId)
: acc,
state.visualization.state
),
});
}
layerIds.forEach((layerId) => {
const layerDatasourceId = Object.entries(props.datasourceMap).find(
([datasourceId, datasource]) =>
state.datasourceStates[datasourceId] &&
datasource.getLayers(state.datasourceStates[datasourceId].state).includes(layerId)
)![0];
dispatch({
type: 'UPDATE_LAYER',
layerId,
datasourceId: layerDatasourceId,
updater: props.datasourceMap[layerDatasourceId].removeLayer,
});
});
},
}),
[
activeData,
activeVisualization,
datasourceLayers,
dateRange,
query,
filters,
searchSessionId,
props.palettes,
props.datasourceMap,
state.activeDatasourceId,
state.datasourceStates,
state.visualization.state,
]
);
useEffect(
() => {
if (persistedDoc) {
dispatch({
type: 'VISUALIZATION_LOADED',
doc: {
...persistedDoc,
state: {
...persistedDoc.state,
visualization: persistedDoc.visualizationType
? props.visualizationMap[persistedDoc.visualizationType].initialize(
framePublicAPI,
persistedDoc.state.visualization
)
: persistedDoc.state.visualization,
},
},
});
} else {
dispatch({
type: 'RESET',
state: getInitialState(props),
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[persistedDoc]
);
// Initialize visualization as soon as all datasources are ready
useEffect(
() => {
if (allLoaded && state.visualization.state === null && activeVisualization) {
const initialVisualizationState = activeVisualization.initialize(framePublicAPI);
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
updater: initialVisualizationState,
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[allLoaded, activeVisualization, state.visualization.state]
);
// Get suggestions for visualize field when all datasources are ready
useEffect(() => {
if (allLoaded && visualizeTriggerFieldContext && !persistedDoc) {
applyVisualizeFieldSuggestions({
datasourceMap: props.datasourceMap,
datasourceStates: state.datasourceStates,
visualizationMap: props.visualizationMap,
activeVisualizationId: state.visualization.activeId,
visualizationState: state.visualization.state,
visualizeTriggerFieldContext,
dispatch,
});
setVisualizeTriggerFieldContext(undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allLoaded]);
const getStateToUpdate: (
arg: {
filterableIndexPatterns: string[];
doc: Document;
isSaveable: boolean;
},
oldState: {
isSaveable: boolean;
indexPatternsForTopNav: IndexPattern[];
persistedDoc?: Document;
lastKnownDoc?: Document;
}
) => Promise<Partial<LensAppState> | undefined> = async (
{ filterableIndexPatterns, doc, isSaveable: incomingIsSaveable },
prevState
) => {
const batchedStateToUpdate: Partial<LensAppState> = {};
if (incomingIsSaveable !== prevState.isSaveable) {
batchedStateToUpdate.isSaveable = incomingIsSaveable;
}
if (!isEqual(prevState.persistedDoc, doc) && !isEqual(prevState.lastKnownDoc, doc)) {
batchedStateToUpdate.lastKnownDoc = doc;
}
const hasIndexPatternsChanged =
prevState.indexPatternsForTopNav.length !== filterableIndexPatterns.length ||
filterableIndexPatterns.some(
(id) => !prevState.indexPatternsForTopNav.find((indexPattern) => indexPattern.id === id)
);
// Update the cached index patterns if the user made a change to any of them
if (hasIndexPatternsChanged) {
const { indexPatterns } = await getAllIndexPatterns(
filterableIndexPatterns,
props.plugins.data.indexPatterns
);
if (indexPatterns) {
batchedStateToUpdate.indexPatternsForTopNav = indexPatterns;
}
}
if (Object.keys(batchedStateToUpdate).length) {
return batchedStateToUpdate;
}
};
// The frame needs to call onChange every time its internal state changes
useEffect(
() => {
const activeDatasource =
state.activeDatasourceId && !state.datasourceStates[state.activeDatasourceId].isLoading
? props.datasourceMap[state.activeDatasourceId]
: undefined;
if (!activeDatasource || !activeVisualization) {
return;
}
const savedObjectFormat = getSavedObjectFormat({
activeDatasources: Object.keys(state.datasourceStates).reduce(
(datasourceMap, datasourceId) => ({
...datasourceMap,
[datasourceId]: props.datasourceMap[datasourceId],
}),
{}
),
visualization: activeVisualization,
state,
framePublicAPI,
});
// Frame loader (app or embeddable) is expected to call this when it loads and updates
// This should be replaced with a top-down state
getStateToUpdate(savedObjectFormat, {
isSaveable,
persistedDoc,
indexPatternsForTopNav,
lastKnownDoc,
}).then((batchedStateToUpdate) => {
if (batchedStateToUpdate) {
dispatchChange(batchedStateToUpdate);
}
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
activeVisualization,
state.datasourceStates,
state.visualization,
activeData,
query,
filters,
savedQuery,
state.title,
dispatchChange,
]
[activeData, datasourceLayers, dateRange, query, filters, searchSessionId]
);
// Using a ref to prevent rerenders in the child components while keeping the latest state
const getSuggestionForField = useRef<(field: DragDropIdentifier) => Suggestion | undefined>();
getSuggestionForField.current = (field: DragDropIdentifier) => {
const { activeDatasourceId, datasourceStates } = state;
const activeVisualizationId = state.visualization.activeId;
const visualizationState = state.visualization.state;
const activeVisualizationId = visualization.activeId;
const visualizationState = visualization.state;
const { visualizationMap, datasourceMap } = props;
if (!field || !activeDatasourceId) {
@ -379,93 +97,77 @@ export function EditorFrame(props: EditorFrameProps) {
const suggestion = getSuggestionForField.current!(field);
if (suggestion) {
trackUiEvent('drop_onto_workspace');
switchToSuggestion(dispatch, suggestion, 'SWITCH_VISUALIZATION');
switchToSuggestion(dispatchLens, suggestion, 'SWITCH_VISUALIZATION');
}
},
[getSuggestionForField]
[getSuggestionForField, dispatchLens]
);
return (
<RootDragDropProvider>
<FrameLayout
isFullscreen={Boolean(state.isFullscreenDatasource)}
isFullscreen={Boolean(isFullscreenDatasource)}
dataPanel={
<DataPanelWrapper
datasourceMap={props.datasourceMap}
activeDatasource={state.activeDatasourceId}
datasourceState={
state.activeDatasourceId
? state.datasourceStates[state.activeDatasourceId].state
: null
}
datasourceIsLoading={
state.activeDatasourceId
? state.datasourceStates[state.activeDatasourceId].isLoading
: true
}
dispatch={dispatch}
core={props.core}
query={query}
dateRange={dateRange}
filters={filters}
plugins={props.plugins}
showNoDataPopover={props.showNoDataPopover}
activeDatasource={activeDatasourceId}
datasourceState={activeDatasourceId ? datasourceStates[activeDatasourceId].state : null}
datasourceIsLoading={
activeDatasourceId ? datasourceStates[activeDatasourceId].isLoading : true
}
dropOntoWorkspace={dropOntoWorkspace}
hasSuggestionForField={hasSuggestionForField}
plugins={props.plugins}
/>
}
configPanel={
allLoaded && (
<ConfigPanelWrapper
activeDatasourceId={state.activeDatasourceId!}
activeDatasourceId={activeDatasourceId!}
datasourceMap={props.datasourceMap}
datasourceStates={state.datasourceStates}
datasourceStates={datasourceStates}
visualizationMap={props.visualizationMap}
activeVisualizationId={state.visualization.activeId}
dispatch={dispatch}
visualizationState={state.visualization.state}
activeVisualizationId={visualization.activeId}
visualizationState={visualization.state}
framePublicAPI={framePublicAPI}
core={props.core}
isFullscreen={Boolean(state.isFullscreenDatasource)}
isFullscreen={Boolean(isFullscreenDatasource)}
/>
)
}
workspacePanel={
allLoaded && (
<WorkspacePanel
title={state.title}
activeDatasourceId={state.activeDatasourceId}
activeVisualizationId={state.visualization.activeId}
activeDatasourceId={activeDatasourceId}
activeVisualizationId={visualization.activeId}
datasourceMap={props.datasourceMap}
datasourceStates={state.datasourceStates}
datasourceStates={datasourceStates}
framePublicAPI={framePublicAPI}
visualizationState={state.visualization.state}
visualizationState={visualization.state}
visualizationMap={props.visualizationMap}
dispatch={dispatch}
isFullscreen={Boolean(state.isFullscreenDatasource)}
isFullscreen={Boolean(isFullscreenDatasource)}
ExpressionRenderer={props.ExpressionRenderer}
core={props.core}
plugins={props.plugins}
visualizeTriggerFieldContext={visualizeTriggerFieldContext}
getSuggestionForField={getSuggestionForField.current}
/>
)
}
suggestionsPanel={
allLoaded &&
!state.isFullscreenDatasource && (
!isFullscreenDatasource && (
<SuggestionPanel
frame={framePublicAPI}
activeDatasourceId={state.activeDatasourceId}
activeVisualizationId={state.visualization.activeId}
datasourceMap={props.datasourceMap}
datasourceStates={state.datasourceStates}
visualizationState={state.visualization.state}
visualizationMap={props.visualizationMap}
dispatch={dispatch}
datasourceMap={props.datasourceMap}
ExpressionRenderer={props.ExpressionRenderer}
stagedPreview={state.stagedPreview}
plugins={props.plugins}
stagedPreview={stagedPreview}
frame={framePublicAPI}
activeVisualizationId={visualization.activeId}
activeDatasourceId={activeDatasourceId}
datasourceStates={datasourceStates}
visualizationState={visualization.state}
/>
)
}

View file

@ -7,4 +7,3 @@
export * from './editor_frame';
export * from './state_helpers';
export * from './state_management';

View file

@ -1,116 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getSavedObjectFormat, Props } from './save';
import { createMockDatasource, createMockFramePublicAPI, createMockVisualization } from '../mocks';
import { esFilters, IIndexPattern, IFieldType } from '../../../../../../src/plugins/data/public';
jest.mock('./expression_helpers');
describe('save editor frame state', () => {
const mockVisualization = createMockVisualization();
const mockDatasource = createMockDatasource('a');
const mockIndexPattern = ({ id: 'indexpattern' } as unknown) as IIndexPattern;
const mockField = ({ name: '@timestamp' } as unknown) as IFieldType;
mockDatasource.getPersistableState.mockImplementation((x) => ({
state: x,
savedObjectReferences: [],
}));
const saveArgs: Props = {
activeDatasources: {
indexpattern: mockDatasource,
},
visualization: mockVisualization,
state: {
title: 'aaa',
datasourceStates: {
indexpattern: {
state: 'hello',
isLoading: false,
},
},
activeDatasourceId: 'indexpattern',
visualization: { activeId: '2', state: {} },
},
framePublicAPI: {
...createMockFramePublicAPI(),
addNewLayer: jest.fn(),
removeLayers: jest.fn(),
datasourceLayers: {
first: mockDatasource.publicAPIMock,
},
query: { query: '', language: 'lucene' },
dateRange: { fromDate: 'now-7d', toDate: 'now' },
filters: [esFilters.buildExistsFilter(mockField, mockIndexPattern)],
},
};
it('transforms from internal state to persisted doc format', async () => {
const datasource = createMockDatasource('a');
datasource.getPersistableState.mockImplementation((state) => ({
state: {
stuff: `${state}_datasource_persisted`,
},
savedObjectReferences: [],
}));
datasource.toExpression.mockReturnValue('my | expr');
const visualization = createMockVisualization();
visualization.toExpression.mockReturnValue('vis | expr');
const { doc, filterableIndexPatterns, isSaveable } = await getSavedObjectFormat({
...saveArgs,
activeDatasources: {
indexpattern: datasource,
},
state: {
title: 'bbb',
datasourceStates: {
indexpattern: {
state: '2',
isLoading: false,
},
},
activeDatasourceId: 'indexpattern',
visualization: { activeId: '3', state: '4' },
},
visualization,
});
expect(filterableIndexPatterns).toEqual([]);
expect(isSaveable).toEqual(true);
expect(doc).toEqual({
id: undefined,
state: {
datasourceStates: {
indexpattern: {
stuff: '2_datasource_persisted',
},
},
visualization: '4',
query: { query: '', language: 'lucene' },
filters: [
{
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

@ -1,79 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { uniq } from 'lodash';
import { SavedObjectReference } from 'kibana/public';
import { EditorFrameState } from './state_management';
import { Document } from '../../persistence/saved_object_store';
import { Datasource, Visualization, FramePublicAPI } from '../../types';
import { extractFilterReferences } from '../../persistence';
import { buildExpression } from './expression_helpers';
export interface Props {
activeDatasources: Record<string, Datasource>;
state: EditorFrameState;
visualization: Visualization;
framePublicAPI: FramePublicAPI;
}
export function getSavedObjectFormat({
activeDatasources,
state,
visualization,
framePublicAPI,
}: 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,
datasourceLayers: framePublicAPI.datasourceLayers,
});
return {
doc: {
savedObjectId: 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,
},
references,
},
filterableIndexPatterns: uniqueFilterableIndexPatternIds,
isSaveable: expression !== null,
};
}

View file

@ -19,7 +19,7 @@ import {
import { buildExpression } from './expression_helpers';
import { Document } from '../../persistence/saved_object_store';
import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public';
import { getActiveDatasourceIdFromDoc } from './state_management';
import { getActiveDatasourceIdFromDoc } from '../../utils';
import { ErrorMessage } from '../types';
import {
getMissingCurrentDatasource,

View file

@ -1,415 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getInitialState, reducer } from './state_management';
import { EditorFrameProps } from './index';
import { Datasource, Visualization } from '../../types';
import { createExpressionRendererMock } from '../mocks';
import { coreMock } from 'src/core/public/mocks';
import { uiActionsPluginMock } from '../../../../../../src/plugins/ui_actions/public/mocks';
import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks';
import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks';
import { chartPluginMock } from 'src/plugins/charts/public/mocks';
describe('editor_frame state management', () => {
describe('initialization', () => {
let props: EditorFrameProps;
beforeEach(() => {
props = {
onError: jest.fn(),
datasourceMap: { testDatasource: ({} as unknown) as Datasource },
visualizationMap: { testVis: ({ initialize: jest.fn() } as unknown) as Visualization },
ExpressionRenderer: createExpressionRendererMock(),
core: coreMock.createStart(),
plugins: {
uiActions: uiActionsPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),
expressions: expressionsPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
},
palettes: chartPluginMock.createPaletteRegistry(),
showNoDataPopover: jest.fn(),
};
});
it('should store initial datasource and visualization', () => {
const initialState = getInitialState(props);
expect(initialState.activeDatasourceId).toEqual('testDatasource');
expect(initialState.visualization.activeId).toEqual('testVis');
});
it('should not initialize visualization but set active id', () => {
const initialState = getInitialState(props);
expect(initialState.visualization.state).toBe(null);
expect(initialState.visualization.activeId).toBe('testVis');
expect(props.visualizationMap.testVis.initialize).not.toHaveBeenCalled();
});
it('should prefill state if doc is passed in', () => {
const initialState = getInitialState({
...props,
doc: {
state: {
datasourceStates: {
testDatasource: { internalState1: '' },
testDatasource2: { internalState2: '' },
},
visualization: {},
query: { query: '', language: 'lucene' },
filters: [],
},
references: [],
title: '',
visualizationType: 'testVis',
},
});
expect(initialState.datasourceStates).toMatchInlineSnapshot(`
Object {
"testDatasource": Object {
"isLoading": true,
"state": Object {
"internalState1": "",
},
},
"testDatasource2": Object {
"isLoading": true,
"state": Object {
"internalState2": "",
},
},
}
`);
expect(initialState.visualization).toMatchInlineSnapshot(`
Object {
"activeId": "testVis",
"state": null,
}
`);
});
it('should not set active id if initiated with empty document and visualizationMap is empty', () => {
const initialState = getInitialState({ ...props, visualizationMap: {} });
expect(initialState.visualization.state).toEqual(null);
expect(initialState.visualization.activeId).toEqual(null);
expect(props.visualizationMap.testVis.initialize).not.toHaveBeenCalled();
});
});
describe('state update', () => {
it('should update the corresponding visualization state on update', () => {
const newVisState = {};
const newState = reducer(
{
datasourceStates: {
testDatasource: {
state: {},
isLoading: false,
},
},
activeDatasourceId: 'testDatasource',
title: 'aaa',
visualization: {
activeId: 'testVis',
state: {},
},
},
{
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: 'testVis',
updater: newVisState,
}
);
expect(newState.visualization.state).toBe(newVisState);
});
it('should update the datasource state with passed in reducer', () => {
const datasourceReducer = jest.fn(() => ({ changed: true }));
const newState = reducer(
{
datasourceStates: {
testDatasource: {
state: {},
isLoading: false,
},
},
activeDatasourceId: 'testDatasource',
title: 'bbb',
visualization: {
activeId: 'testVis',
state: {},
},
},
{
type: 'UPDATE_DATASOURCE_STATE',
updater: datasourceReducer,
datasourceId: 'testDatasource',
}
);
expect(newState.datasourceStates.testDatasource.state).toEqual({ changed: true });
expect(datasourceReducer).toHaveBeenCalledTimes(1);
});
it('should update the layer state with passed in reducer', () => {
const newDatasourceState = {};
const newState = reducer(
{
datasourceStates: {
testDatasource: {
state: {},
isLoading: false,
},
},
activeDatasourceId: 'testDatasource',
title: 'bbb',
visualization: {
activeId: 'testVis',
state: {},
},
},
{
type: 'UPDATE_DATASOURCE_STATE',
updater: newDatasourceState,
datasourceId: 'testDatasource',
}
);
expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState);
});
it('should should switch active visualization', () => {
const testVisState = {};
const newVisState = {};
const newState = reducer(
{
datasourceStates: {
testDatasource: {
state: {},
isLoading: false,
},
},
activeDatasourceId: 'testDatasource',
title: 'ccc',
visualization: {
activeId: 'testVis',
state: testVisState,
},
},
{
type: 'SWITCH_VISUALIZATION',
newVisualizationId: 'testVis2',
initialState: newVisState,
}
);
expect(newState.visualization.state).toBe(newVisState);
});
it('should should switch active visualization and update datasource state', () => {
const testVisState = {};
const newVisState = {};
const newDatasourceState = {};
const newState = reducer(
{
datasourceStates: {
testDatasource: {
state: {},
isLoading: false,
},
},
activeDatasourceId: 'testDatasource',
title: 'ddd',
visualization: {
activeId: 'testVis',
state: testVisState,
},
},
{
type: 'SWITCH_VISUALIZATION',
newVisualizationId: 'testVis2',
initialState: newVisState,
datasourceState: newDatasourceState,
datasourceId: 'testDatasource',
}
);
expect(newState.visualization.state).toBe(newVisState);
expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState);
});
it('should should switch active datasource and initialize new state', () => {
const newState = reducer(
{
datasourceStates: {
testDatasource: {
state: {},
isLoading: false,
},
},
activeDatasourceId: 'testDatasource',
title: 'eee',
visualization: {
activeId: 'testVis',
state: {},
},
},
{
type: 'SWITCH_DATASOURCE',
newDatasourceId: 'testDatasource2',
}
);
expect(newState.activeDatasourceId).toEqual('testDatasource2');
expect(newState.datasourceStates.testDatasource2.isLoading).toEqual(true);
});
it('not initialize already initialized datasource on switch', () => {
const datasource2State = {};
const newState = reducer(
{
datasourceStates: {
testDatasource: {
state: {},
isLoading: false,
},
testDatasource2: {
state: datasource2State,
isLoading: false,
},
},
activeDatasourceId: 'testDatasource',
title: 'eee',
visualization: {
activeId: 'testVis',
state: {},
},
},
{
type: 'SWITCH_DATASOURCE',
newDatasourceId: 'testDatasource2',
}
);
expect(newState.activeDatasourceId).toEqual('testDatasource2');
expect(newState.datasourceStates.testDatasource2.state).toBe(datasource2State);
});
it('should reset the state', () => {
const newState = reducer(
{
datasourceStates: {
a: {
state: {},
isLoading: false,
},
},
activeDatasourceId: 'a',
title: 'jjj',
visualization: {
activeId: 'b',
state: {},
},
},
{
type: 'RESET',
state: {
datasourceStates: {
z: {
isLoading: false,
state: { hola: 'muchacho' },
},
},
activeDatasourceId: 'z',
persistedId: 'bar',
title: 'lll',
visualization: {
activeId: 'q',
state: { my: 'viz' },
},
},
}
);
expect(newState).toMatchObject({
datasourceStates: {
z: {
isLoading: false,
state: { hola: 'muchacho' },
},
},
activeDatasourceId: 'z',
persistedId: 'bar',
visualization: {
activeId: 'q',
state: { my: 'viz' },
},
});
});
it('should load the state from the doc', () => {
const newState = reducer(
{
datasourceStates: {
a: {
state: {},
isLoading: false,
},
},
activeDatasourceId: 'a',
title: 'mmm',
visualization: {
activeId: 'b',
state: {},
},
},
{
type: 'VISUALIZATION_LOADED',
doc: {
savedObjectId: 'b',
state: {
datasourceStates: { a: { foo: 'c' } },
visualization: { bar: 'd' },
query: { query: '', language: 'lucene' },
filters: [],
},
title: 'heyo!',
description: 'My lens',
type: 'lens',
visualizationType: 'line',
references: [],
},
}
);
expect(newState).toEqual({
activeDatasourceId: 'a',
datasourceStates: {
a: {
isLoading: true,
state: {
foo: 'c',
},
},
},
persistedId: 'b',
title: 'heyo!',
description: 'My lens',
visualization: {
activeId: 'line',
state: {
bar: 'd',
},
},
});
});
});
});

View file

@ -1,293 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EditorFrameProps } from './index';
import { Document } from '../../persistence/saved_object_store';
export interface PreviewState {
visualization: {
activeId: string | null;
state: unknown;
};
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>;
}
export interface EditorFrameState extends PreviewState {
persistedId?: string;
title: string;
description?: string;
stagedPreview?: PreviewState;
activeDatasourceId: string | null;
isFullscreenDatasource?: boolean;
}
export type Action =
| {
type: 'RESET';
state: EditorFrameState;
}
| {
type: 'UPDATE_TITLE';
title: string;
}
| {
type: 'UPDATE_STATE';
// Just for diagnostics, so we can determine what action
// caused this update.
subType: string;
updater: (prevState: EditorFrameState) => EditorFrameState;
}
| {
type: 'UPDATE_DATASOURCE_STATE';
updater: unknown | ((prevState: unknown) => unknown);
datasourceId: string;
clearStagedPreview?: boolean;
}
| {
type: 'UPDATE_VISUALIZATION_STATE';
visualizationId: string;
updater: unknown | ((state: unknown) => unknown);
clearStagedPreview?: boolean;
}
| {
type: 'UPDATE_LAYER';
layerId: string;
datasourceId: string;
updater: (state: unknown, layerId: string) => unknown;
}
| {
type: 'VISUALIZATION_LOADED';
doc: Document;
}
| {
type: 'SWITCH_VISUALIZATION';
newVisualizationId: string;
initialState: unknown;
}
| {
type: 'SWITCH_VISUALIZATION';
newVisualizationId: string;
initialState: unknown;
datasourceState: unknown;
datasourceId: string;
}
| {
type: 'SELECT_SUGGESTION';
newVisualizationId: string;
initialState: unknown;
datasourceState: unknown;
datasourceId: string;
}
| {
type: 'ROLLBACK_SUGGESTION';
}
| {
type: 'SUBMIT_SUGGESTION';
}
| {
type: 'SWITCH_DATASOURCE';
newDatasourceId: string;
}
| {
type: 'TOGGLE_FULLSCREEN';
};
export function getActiveDatasourceIdFromDoc(doc?: Document) {
if (!doc) {
return null;
}
const [firstDatasourceFromDoc] = Object.keys(doc.state.datasourceStates);
return firstDatasourceFromDoc || null;
}
export const getInitialState = (
params: EditorFrameProps & { doc?: Document }
): EditorFrameState => {
const datasourceStates: EditorFrameState['datasourceStates'] = {};
const initialDatasourceId =
getActiveDatasourceIdFromDoc(params.doc) || Object.keys(params.datasourceMap)[0] || null;
const initialVisualizationId =
(params.doc && params.doc.visualizationType) || Object.keys(params.visualizationMap)[0] || null;
if (params.doc) {
Object.entries(params.doc.state.datasourceStates).forEach(([datasourceId, state]) => {
datasourceStates[datasourceId] = { isLoading: true, state };
});
} else if (initialDatasourceId) {
datasourceStates[initialDatasourceId] = {
state: null,
isLoading: true,
};
}
return {
title: '',
datasourceStates,
activeDatasourceId: initialDatasourceId,
visualization: {
state: null,
activeId: initialVisualizationId,
},
};
};
export const reducer = (state: EditorFrameState, action: Action): EditorFrameState => {
switch (action.type) {
case 'RESET':
return action.state;
case 'UPDATE_TITLE':
return { ...state, title: action.title };
case 'UPDATE_STATE':
return action.updater(state);
case 'UPDATE_LAYER':
return {
...state,
datasourceStates: {
...state.datasourceStates,
[action.datasourceId]: {
...state.datasourceStates[action.datasourceId],
state: action.updater(
state.datasourceStates[action.datasourceId].state,
action.layerId
),
},
},
};
case 'VISUALIZATION_LOADED':
return {
...state,
persistedId: action.doc.savedObjectId,
title: action.doc.title,
description: action.doc.description,
datasourceStates: Object.entries(action.doc.state.datasourceStates).reduce(
(stateMap, [datasourceId, datasourceState]) => ({
...stateMap,
[datasourceId]: {
isLoading: true,
state: datasourceState,
},
}),
{}
),
activeDatasourceId: getActiveDatasourceIdFromDoc(action.doc),
visualization: {
...state.visualization,
activeId: action.doc.visualizationType,
state: action.doc.state.visualization,
},
};
case 'SWITCH_DATASOURCE':
return {
...state,
datasourceStates: {
...state.datasourceStates,
[action.newDatasourceId]: state.datasourceStates[action.newDatasourceId] || {
state: null,
isLoading: true,
},
},
activeDatasourceId: action.newDatasourceId,
};
case 'SWITCH_VISUALIZATION':
return {
...state,
datasourceStates:
'datasourceId' in action && action.datasourceId
? {
...state.datasourceStates,
[action.datasourceId]: {
...state.datasourceStates[action.datasourceId],
state: action.datasourceState,
},
}
: state.datasourceStates,
visualization: {
...state.visualization,
activeId: action.newVisualizationId,
state: action.initialState,
},
stagedPreview: undefined,
};
case 'SELECT_SUGGESTION':
return {
...state,
datasourceStates:
'datasourceId' in action && action.datasourceId
? {
...state.datasourceStates,
[action.datasourceId]: {
...state.datasourceStates[action.datasourceId],
state: action.datasourceState,
},
}
: state.datasourceStates,
visualization: {
...state.visualization,
activeId: action.newVisualizationId,
state: action.initialState,
},
stagedPreview: state.stagedPreview || {
datasourceStates: state.datasourceStates,
visualization: state.visualization,
},
};
case 'ROLLBACK_SUGGESTION':
return {
...state,
...(state.stagedPreview || {}),
stagedPreview: undefined,
};
case 'SUBMIT_SUGGESTION':
return {
...state,
stagedPreview: undefined,
};
case 'UPDATE_DATASOURCE_STATE':
return {
...state,
datasourceStates: {
...state.datasourceStates,
[action.datasourceId]: {
state:
typeof action.updater === 'function'
? action.updater(state.datasourceStates[action.datasourceId].state)
: action.updater,
isLoading: false,
},
},
stagedPreview: action.clearStagedPreview ? undefined : state.stagedPreview,
};
case 'UPDATE_VISUALIZATION_STATE':
if (!state.visualization.activeId) {
throw new Error('Invariant: visualization state got updated without active visualization');
}
// This is a safeguard that prevents us from accidentally updating the
// wrong visualization. This occurs in some cases due to the uncoordinated
// way we manage state across plugins.
if (state.visualization.activeId !== action.visualizationId) {
return state;
}
return {
...state,
visualization: {
...state.visualization,
state:
typeof action.updater === 'function'
? action.updater(state.visualization.state)
: action.updater,
},
stagedPreview: action.clearStagedPreview ? undefined : state.stagedPreview,
};
case 'TOGGLE_FULLSCREEN':
return { ...state, isFullscreenDatasource: !state.isFullscreenDatasource };
default:
return state;
}
};

View file

@ -6,7 +6,7 @@
*/
import { getSuggestions, getTopSuggestionForField } from './suggestion_helpers';
import { createMockVisualization, createMockDatasource, DatasourceMock } from '../mocks';
import { createMockVisualization, createMockDatasource, DatasourceMock } from '../../mocks';
import { TableSuggestion, DatasourceSuggestion, Visualization } from '../../types';
import { PaletteOutput } from 'src/plugins/charts/public';

View file

@ -19,8 +19,8 @@ import {
DatasourceSuggestion,
DatasourcePublicAPI,
} from '../../types';
import { Action } from './state_management';
import { DragDropIdentifier } from '../../drag_drop';
import { LensDispatch, selectSuggestion, switchVisualization } from '../../state_management';
export interface Suggestion {
visualizationId: string;
@ -132,14 +132,13 @@ export function getSuggestions({
).sort((a, b) => b.score - a.score);
}
export function applyVisualizeFieldSuggestions({
export function getVisualizeFieldSuggestions({
datasourceMap,
datasourceStates,
visualizationMap,
activeVisualizationId,
visualizationState,
visualizeTriggerFieldContext,
dispatch,
}: {
datasourceMap: Record<string, Datasource>;
datasourceStates: Record<
@ -154,8 +153,7 @@ export function applyVisualizeFieldSuggestions({
subVisualizationId?: string;
visualizationState: unknown;
visualizeTriggerFieldContext?: VisualizeFieldContext;
dispatch: (action: Action) => void;
}): void {
}): Suggestion | undefined {
const suggestions = getSuggestions({
datasourceMap,
datasourceStates,
@ -165,9 +163,7 @@ export function applyVisualizeFieldSuggestions({
visualizeTriggerFieldContext,
});
if (suggestions.length) {
const selectedSuggestion =
suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0];
switchToSuggestion(dispatch, selectedSuggestion, 'SWITCH_VISUALIZATION');
return suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0];
}
}
@ -207,22 +203,25 @@ function getVisualizationSuggestions(
}
export function switchToSuggestion(
dispatch: (action: Action) => void,
dispatchLens: LensDispatch,
suggestion: Pick<
Suggestion,
'visualizationId' | 'visualizationState' | 'datasourceState' | 'datasourceId'
>,
type: 'SWITCH_VISUALIZATION' | 'SELECT_SUGGESTION' = 'SELECT_SUGGESTION'
) {
const action: Action = {
type,
const pickedSuggestion = {
newVisualizationId: suggestion.visualizationId,
initialState: suggestion.visualizationState,
datasourceState: suggestion.datasourceState,
datasourceId: suggestion.datasourceId!,
};
dispatch(action);
dispatchLens(
type === 'SELECT_SUGGESTION'
? selectSuggestion(pickedSuggestion)
: switchVisualization(pickedSuggestion)
);
}
export function getTopSuggestionForField(

View file

@ -6,7 +6,6 @@
*/
import React from 'react';
import { mountWithIntl as mount } from '@kbn/test/jest';
import { Visualization } from '../../types';
import {
createMockVisualization,
@ -14,15 +13,15 @@ import {
createExpressionRendererMock,
DatasourceMock,
createMockFramePublicAPI,
} from '../mocks';
} from '../../mocks';
import { act } from 'react-dom/test-utils';
import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public';
import { esFilters, IFieldType, IIndexPattern } from '../../../../../../src/plugins/data/public';
import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel';
import { getSuggestions, Suggestion } from './suggestion_helpers';
import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui';
import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks';
import { LensIconChartDatatable } from '../../assets/chart_datatable';
import { mountWithProvider } from '../../mocks';
jest.mock('./suggestion_helpers');
@ -33,7 +32,6 @@ describe('suggestion_panel', () => {
let mockDatasource: DatasourceMock;
let expressionRendererMock: ReactExpressionRendererType;
let dispatchMock: jest.Mock;
const suggestion1State = { suggestion1: true };
const suggestion2State = { suggestion2: true };
@ -44,7 +42,6 @@ describe('suggestion_panel', () => {
mockVisualization = createMockVisualization();
mockDatasource = createMockDatasource('a');
expressionRendererMock = createExpressionRendererMock();
dispatchMock = jest.fn();
getSuggestionsMock.mockReturnValue([
{
@ -84,18 +81,16 @@ describe('suggestion_panel', () => {
vis2: createMockVisualization(),
},
visualizationState: {},
dispatch: dispatchMock,
ExpressionRenderer: expressionRendererMock,
frame: createMockFramePublicAPI(),
plugins: { data: dataPluginMock.createStartContract() },
};
});
it('should list passed in suggestions', () => {
const wrapper = mount(<SuggestionPanel {...defaultProps} />);
it('should list passed in suggestions', async () => {
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
expect(
wrapper
instance
.find('[data-test-subj="lnsSuggestion"]')
.find(EuiPanel)
.map((el) => el.parents(EuiToolTip).prop('content'))
@ -129,84 +124,148 @@ describe('suggestion_panel', () => {
};
});
it('should not update suggestions if current state is moved to staged preview', () => {
const wrapper = mount(<SuggestionPanel {...defaultProps} />);
it('should not update suggestions if current state is moved to staged preview', async () => {
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
getSuggestionsMock.mockClear();
wrapper.setProps({
instance.setProps({
stagedPreview,
...suggestionState,
});
wrapper.update();
instance.update();
expect(getSuggestionsMock).not.toHaveBeenCalled();
});
it('should update suggestions if staged preview is removed', () => {
const wrapper = mount(<SuggestionPanel {...defaultProps} />);
it('should update suggestions if staged preview is removed', async () => {
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
getSuggestionsMock.mockClear();
wrapper.setProps({
instance.setProps({
stagedPreview,
...suggestionState,
});
wrapper.update();
wrapper.setProps({
instance.update();
instance.setProps({
stagedPreview: undefined,
...suggestionState,
});
wrapper.update();
instance.update();
expect(getSuggestionsMock).toHaveBeenCalledTimes(1);
});
it('should highlight currently active suggestion', () => {
const wrapper = mount(<SuggestionPanel {...defaultProps} />);
it('should highlight currently active suggestion', async () => {
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
act(() => {
wrapper.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click');
instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click');
});
wrapper.update();
instance.update();
expect(wrapper.find('[data-test-subj="lnsSuggestion"]').at(2).prop('className')).toContain(
expect(instance.find('[data-test-subj="lnsSuggestion"]').at(2).prop('className')).toContain(
'lnsSuggestionPanel__button-isSelected'
);
});
it('should rollback suggestion if current panel is clicked', () => {
const wrapper = mount(<SuggestionPanel {...defaultProps} />);
it('should rollback suggestion if current panel is clicked', async () => {
const { instance, lensStore } = await mountWithProvider(
<SuggestionPanel {...defaultProps} />
);
act(() => {
wrapper.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click');
instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click');
});
wrapper.update();
instance.update();
act(() => {
wrapper.find('[data-test-subj="lnsSuggestion"]').at(0).simulate('click');
instance.find('[data-test-subj="lnsSuggestion"]').at(0).simulate('click');
});
wrapper.update();
instance.update();
expect(dispatchMock).toHaveBeenCalledWith({
type: 'ROLLBACK_SUGGESTION',
expect(lensStore.dispatch).toHaveBeenCalledWith({
type: 'lens/rollbackSuggestion',
});
});
});
it('should dispatch visualization switch action if suggestion is clicked', () => {
const wrapper = mount(<SuggestionPanel {...defaultProps} />);
it('should dispatch visualization switch action if suggestion is clicked', async () => {
const { instance, lensStore } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
act(() => {
wrapper.find('button[data-test-subj="lnsSuggestion"]').at(1).simulate('click');
instance.find('button[data-test-subj="lnsSuggestion"]').at(1).simulate('click');
});
wrapper.update();
instance.update();
expect(dispatchMock).toHaveBeenCalledWith(
expect(lensStore.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'SELECT_SUGGESTION',
initialState: suggestion1State,
type: 'lens/selectSuggestion',
payload: {
datasourceId: undefined,
datasourceState: {},
initialState: { suggestion1: true },
newVisualizationId: 'vis',
},
})
);
});
it('should render render icon if there is no preview expression', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
getSuggestionsMock.mockReturnValue([
{
datasourceState: {},
previewIcon: LensIconChartDatatable,
score: 0.5,
visualizationState: suggestion1State,
visualizationId: 'vis',
title: 'Suggestion1',
},
{
datasourceState: {},
previewIcon: 'empty',
score: 0.5,
visualizationState: suggestion2State,
visualizationId: 'vis',
title: 'Suggestion2',
previewExpression: 'test | expression',
},
] as Suggestion[]);
(mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce(undefined);
(mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('test | expression');
// this call will go to the currently active visualization
(mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('current | preview');
mockDatasource.toExpression.mockReturnValue('datasource_expression');
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
expect(instance.find(EuiIcon)).toHaveLength(1);
expect(instance.find(EuiIcon).prop('type')).toEqual(LensIconChartDatatable);
});
it('should return no suggestion if visualization has missing index-patterns', async () => {
// create a layer that is referencing an indexPatterns not retrieved by the datasource
const missingIndexPatternsState = {
layers: { indexPatternId: 'a' },
indexPatterns: {},
};
mockDatasource.checkIntegrity.mockReturnValue(['a']);
const newProps = {
...defaultProps,
datasourceStates: {
mock: {
...defaultProps.datasourceStates.mock,
state: missingIndexPatternsState,
},
},
};
const { instance } = await mountWithProvider(<SuggestionPanel {...newProps} />);
expect(instance.html()).toEqual(null);
});
it('should render preview expression if there is one', () => {
mockDatasource.getLayers.mockReturnValue(['first']);
(getSuggestions as jest.Mock).mockReturnValue([
@ -235,7 +294,7 @@ describe('suggestion_panel', () => {
const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
mount(
mountWithProvider(
<SuggestionPanel
{...defaultProps}
frame={{
@ -255,60 +314,4 @@ describe('suggestion_panel', () => {
| expression"
`);
});
it('should render render icon if there is no preview expression', () => {
mockDatasource.getLayers.mockReturnValue(['first']);
getSuggestionsMock.mockReturnValue([
{
datasourceState: {},
previewIcon: LensIconChartDatatable,
score: 0.5,
visualizationState: suggestion1State,
visualizationId: 'vis',
title: 'Suggestion1',
},
{
datasourceState: {},
previewIcon: 'empty',
score: 0.5,
visualizationState: suggestion2State,
visualizationId: 'vis',
title: 'Suggestion2',
previewExpression: 'test | expression',
},
] as Suggestion[]);
(mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce(undefined);
(mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('test | expression');
// this call will go to the currently active visualization
(mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('current | preview');
mockDatasource.toExpression.mockReturnValue('datasource_expression');
const wrapper = mount(<SuggestionPanel {...defaultProps} />);
expect(wrapper.find(EuiIcon)).toHaveLength(1);
expect(wrapper.find(EuiIcon).prop('type')).toEqual(LensIconChartDatatable);
});
it('should return no suggestion if visualization has missing index-patterns', () => {
// create a layer that is referencing an indexPatterns not retrieved by the datasource
const missingIndexPatternsState = {
layers: { indexPatternId: 'a' },
indexPatterns: {},
};
mockDatasource.checkIntegrity.mockReturnValue(['a']);
const newProps = {
...defaultProps,
datasourceStates: {
mock: {
...defaultProps.datasourceStates.mock,
state: missingIndexPatternsState,
},
},
};
const wrapper = mount(<SuggestionPanel {...newProps} />);
expect(wrapper.html()).toEqual(null);
});
});

View file

@ -24,8 +24,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 { DataPublicPluginStart, ExecutionContextSearch } from 'src/plugins/data/public';
import { Action, PreviewState } from './state_management';
import { ExecutionContextSearch } from 'src/plugins/data/public';
import { Datasource, Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types';
import { getSuggestions, switchToSuggestion } from './suggestion_helpers';
import {
@ -35,6 +34,12 @@ import {
import { prependDatasourceExpression } from './expression_helpers';
import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry';
import { getMissingIndexPattern, validateDatasourceAndVisualization } from './state_helpers';
import {
PreviewState,
rollbackSuggestion,
submitSuggestion,
useLensDispatch,
} from '../../state_management';
const MAX_SUGGESTIONS_DISPLAYED = 5;
@ -51,11 +56,9 @@ export interface SuggestionPanelProps {
activeVisualizationId: string | null;
visualizationMap: Record<string, Visualization>;
visualizationState: unknown;
dispatch: (action: Action) => void;
ExpressionRenderer: ReactExpressionRendererType;
frame: FramePublicAPI;
stagedPreview?: PreviewState;
plugins: { data: DataPublicPluginStart };
}
const PreviewRenderer = ({
@ -170,12 +173,12 @@ export function SuggestionPanel({
activeVisualizationId,
visualizationMap,
visualizationState,
dispatch,
frame,
ExpressionRenderer: ExpressionRendererComponent,
stagedPreview,
plugins,
}: SuggestionPanelProps) {
const dispatchLens = useLensDispatch();
const currentDatasourceStates = stagedPreview ? stagedPreview.datasourceStates : datasourceStates;
const currentVisualizationState = stagedPreview
? stagedPreview.visualization.state
@ -320,9 +323,7 @@ export function SuggestionPanel({
if (lastSelectedSuggestion !== -1) {
trackSuggestionEvent('back_to_current');
setLastSelectedSuggestion(-1);
dispatch({
type: 'ROLLBACK_SUGGESTION',
});
dispatchLens(rollbackSuggestion());
}
}
@ -352,9 +353,7 @@ export function SuggestionPanel({
iconType="refresh"
onClick={() => {
trackUiEvent('suggestion_confirmed');
dispatch({
type: 'SUBMIT_SUGGESTION',
});
dispatchLens(submitSuggestion());
}}
>
{i18n.translate('xpack.lens.sugegstion.refreshSuggestionLabel', {
@ -401,7 +400,7 @@ export function SuggestionPanel({
rollbackToCurrentVisualization();
} else {
setLastSelectedSuggestion(index);
switchToSuggestion(dispatch, suggestion);
switchToSuggestion(dispatchLens, suggestion);
}
}}
selected={index === lastSelectedSuggestion}

View file

@ -11,7 +11,8 @@ import {
createMockVisualization,
createMockFramePublicAPI,
createMockDatasource,
} from '../../mocks';
} from '../../../mocks';
import { mountWithProvider } from '../../../mocks';
// Tests are executed in a jsdom environment who does not have sizing methods,
// thus the AutoSizer will always compute a 0x0 size space
@ -25,9 +26,7 @@ jest.mock('react-virtualized-auto-sizer', () => {
};
});
import { mountWithIntl as mount } from '@kbn/test/jest';
import { Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../../types';
import { Action } from '../state_management';
import { ChartSwitch } from './chart_switch';
import { PaletteOutput } from 'src/plugins/charts/public';
@ -157,6 +156,8 @@ describe('chart_switch', () => {
keptLayerIds: ['a'],
},
]);
datasource.getLayers.mockReturnValue(['a']);
return {
testDatasource: datasource,
};
@ -171,78 +172,94 @@ describe('chart_switch', () => {
};
}
function showFlyout(component: ReactWrapper) {
component.find('[data-test-subj="lnsChartSwitchPopover"]').first().simulate('click');
function showFlyout(instance: ReactWrapper) {
instance.find('[data-test-subj="lnsChartSwitchPopover"]').first().simulate('click');
}
function switchTo(subType: string, component: ReactWrapper) {
showFlyout(component);
component.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).first().simulate('click');
function switchTo(subType: string, instance: ReactWrapper) {
showFlyout(instance);
instance.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).first().simulate('click');
}
function getMenuItem(subType: string, component: ReactWrapper) {
showFlyout(component);
return component.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).first();
function getMenuItem(subType: string, instance: ReactWrapper) {
showFlyout(instance);
return instance.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).first();
}
it('should use suggested state if there is a suggestion from the target visualization', () => {
const dispatch = jest.fn();
it('should use suggested state if there is a suggestion from the target visualization', async () => {
const visualizations = mockVisualizations();
const component = mount(
const { instance, lensStore } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={'state from a'}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={mockFrame(['a'])}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
/>,
{
preloadedState: {
visualization: {
activeId: 'visA',
state: 'state from a',
},
},
}
);
switchTo('visB', component);
switchTo('visB', instance);
expect(dispatch).toHaveBeenCalledWith({
initialState: 'suggestion visB',
newVisualizationId: 'visB',
type: 'SWITCH_VISUALIZATION',
datasourceId: 'testDatasource',
datasourceState: {},
expect(lensStore.dispatch).toHaveBeenCalledWith({
type: 'lens/switchVisualization',
payload: {
initialState: 'suggestion visB',
newVisualizationId: 'visB',
datasourceId: 'testDatasource',
datasourceState: {},
},
});
});
it('should use initial state if there is no suggestion from the target visualization', () => {
const dispatch = jest.fn();
it('should use initial state if there is no suggestion from the target visualization', async () => {
const visualizations = mockVisualizations();
visualizations.visB.getSuggestions.mockReturnValueOnce([]);
const frame = mockFrame(['a']);
(frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]);
const component = mount(
const datasourceMap = mockDatasourceMap();
const datasourceStates = mockDatasourceStates();
const { instance, lensStore } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={{}}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
datasourceMap={datasourceMap}
/>,
{
preloadedState: {
datasourceStates,
activeDatasourceId: 'testDatasource',
visualization: {
activeId: 'visA',
state: {},
},
},
}
);
switchTo('visB', component);
expect(frame.removeLayers).toHaveBeenCalledWith(['a']);
expect(dispatch).toHaveBeenCalledWith({
initialState: 'visB initial state',
newVisualizationId: 'visB',
type: 'SWITCH_VISUALIZATION',
switchTo('visB', instance);
expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'a'); // from preloaded state
expect(lensStore.dispatch).toHaveBeenCalledWith({
type: 'lens/switchVisualization',
payload: {
initialState: 'visB initial state',
newVisualizationId: 'visB',
},
});
expect(lensStore.dispatch).toHaveBeenCalledWith({
type: 'lens/updateLayer',
payload: expect.objectContaining({
datasourceId: 'testDatasource',
layerId: 'a',
}),
});
});
it('should indicate data loss if not all columns will be used', () => {
const dispatch = jest.fn();
it('should indicate data loss if not all columns will be used', async () => {
const visualizations = mockVisualizations();
const frame = mockFrame(['a']);
@ -282,53 +299,59 @@ describe('chart_switch', () => {
{ columnId: 'col3' },
]);
const component = mount(
const { instance } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={{}}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
/>,
{
preloadedState: {
visualization: {
activeId: 'visA',
state: {},
},
},
}
);
expect(
getMenuItem('visB', component)
getMenuItem('visB', instance)
.find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]')
.first()
.props().type
).toEqual('alert');
});
it('should indicate data loss if not all layers will be used', () => {
const dispatch = jest.fn();
it('should indicate data loss if not all layers will be used', async () => {
const visualizations = mockVisualizations();
const frame = mockFrame(['a', 'b']);
const component = mount(
const { instance } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={{}}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
/>,
{
preloadedState: {
visualization: {
activeId: 'visA',
state: {},
},
},
}
);
expect(
getMenuItem('visB', component)
getMenuItem('visB', instance)
.find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]')
.first()
.props().type
).toEqual('alert');
});
it('should support multi-layer suggestions without data loss', () => {
const dispatch = jest.fn();
it('should support multi-layer suggestions without data loss', async () => {
const visualizations = mockVisualizations();
const frame = mockFrame(['a', 'b']);
@ -355,75 +378,85 @@ describe('chart_switch', () => {
},
]);
const component = mount(
const { instance } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={{}}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={datasourceMap}
datasourceStates={mockDatasourceStates()}
/>
/>,
{
preloadedState: {
visualization: {
activeId: 'visA',
state: {},
},
},
}
);
expect(
getMenuItem('visB', component).find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]')
getMenuItem('visB', instance).find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]')
).toHaveLength(0);
});
it('should indicate data loss if no data will be used', () => {
const dispatch = jest.fn();
it('should indicate data loss if no data will be used', async () => {
const visualizations = mockVisualizations();
visualizations.visB.getSuggestions.mockReturnValueOnce([]);
const frame = mockFrame(['a']);
const component = mount(
const { instance } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={{}}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
/>,
{
preloadedState: {
visualization: {
activeId: 'visA',
state: {},
},
},
}
);
expect(
getMenuItem('visB', component)
getMenuItem('visB', instance)
.find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]')
.first()
.props().type
).toEqual('alert');
});
it('should not indicate data loss if there is no data', () => {
const dispatch = jest.fn();
it('should not indicate data loss if there is no data', async () => {
const visualizations = mockVisualizations();
visualizations.visB.getSuggestions.mockReturnValueOnce([]);
const frame = mockFrame(['a']);
(frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]);
const component = mount(
const { instance } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={{}}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
/>,
{
preloadedState: {
visualization: {
activeId: 'visA',
state: {},
},
},
}
);
expect(
getMenuItem('visB', component).find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]')
getMenuItem('visB', instance).find('[data-test-subj="lnsChartSwitchPopoverAlert_visB"]')
).toHaveLength(0);
});
it('should not show a warning when the subvisualization is the same', () => {
const dispatch = jest.fn();
it('should not show a warning when the subvisualization is the same', async () => {
const frame = mockFrame(['a', 'b', 'c']);
const visualizations = mockVisualizations();
visualizations.visC.getVisualizationTypeId.mockReturnValue('subvisC2');
@ -431,64 +464,81 @@ describe('chart_switch', () => {
visualizations.visC.switchVisualizationType = switchVisualizationType;
const component = mount(
const datasourceMap = mockDatasourceMap();
const datasourceStates = mockDatasourceStates();
const { instance } = await mountWithProvider(
<ChartSwitch
visualizationId="visC"
visualizationState={{ type: 'subvisC2' }}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
datasourceMap={datasourceMap}
/>,
{
preloadedState: {
datasourceStates,
activeDatasourceId: 'testDatasource',
visualization: {
activeId: 'visC',
state: { type: 'subvisC2' },
},
},
}
);
expect(
getMenuItem('subvisC2', component).find(
getMenuItem('subvisC2', instance).find(
'[data-test-subj="lnsChartSwitchPopoverAlert_subvisC2"]'
)
).toHaveLength(0);
});
it('should get suggestions when switching subvisualization', () => {
const dispatch = jest.fn();
it('should get suggestions when switching subvisualization', async () => {
const visualizations = mockVisualizations();
visualizations.visB.getSuggestions.mockReturnValueOnce([]);
const frame = mockFrame(['a', 'b', 'c']);
const datasourceMap = mockDatasourceMap();
datasourceMap.testDatasource.getLayers.mockReturnValue(['a', 'b', 'c']);
const datasourceStates = mockDatasourceStates();
const component = mount(
const { instance, lensStore } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={{}}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
datasourceMap={datasourceMap}
/>,
{
preloadedState: {
datasourceStates,
visualization: {
activeId: 'visA',
state: {},
},
},
}
);
switchTo('visB', component);
expect(frame.removeLayers).toHaveBeenCalledTimes(1);
expect(frame.removeLayers).toHaveBeenCalledWith(['a', 'b', 'c']);
switchTo('visB', instance);
expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'a');
expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith(undefined, 'b');
expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith(undefined, 'c');
expect(visualizations.visB.getSuggestions).toHaveBeenCalledWith(
expect.objectContaining({
keptLayerIds: ['a'],
})
);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'SWITCH_VISUALIZATION',
expect(lensStore.dispatch).toHaveBeenCalledWith({
type: 'lens/switchVisualization',
payload: {
datasourceId: undefined,
datasourceState: undefined,
initialState: 'visB initial state',
})
);
newVisualizationId: 'visB',
},
});
});
it('should query main palette from active chart and pass into suggestions', () => {
const dispatch = jest.fn();
it('should query main palette from active chart and pass into suggestions', async () => {
const visualizations = mockVisualizations();
const mockPalette: PaletteOutput = { type: 'palette', name: 'mock' };
visualizations.visA.getMainPalette = jest.fn(() => mockPalette);
@ -496,19 +546,26 @@ describe('chart_switch', () => {
const frame = mockFrame(['a', 'b', 'c']);
const currentVisState = {};
const component = mount(
const datasourceMap = mockDatasourceMap();
datasourceMap.testDatasource.getLayers.mockReturnValue(['a', 'b', 'c']);
const { instance } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={currentVisState}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
datasourceMap={datasourceMap}
/>,
{
preloadedState: {
visualization: {
activeId: 'visA',
state: currentVisState,
},
},
}
);
switchTo('visB', component);
switchTo('visB', instance);
expect(visualizations.visA.getMainPalette).toHaveBeenCalledWith(currentVisState);
@ -520,67 +577,76 @@ describe('chart_switch', () => {
);
});
it('should not remove layers when switching between subtypes', () => {
const dispatch = jest.fn();
it('should not remove layers when switching between subtypes', async () => {
const frame = mockFrame(['a', 'b', 'c']);
const visualizations = mockVisualizations();
const switchVisualizationType = jest.fn(() => 'switched');
visualizations.visC.switchVisualizationType = switchVisualizationType;
const component = mount(
const datasourceMap = mockDatasourceMap();
const { instance, lensStore } = await mountWithProvider(
<ChartSwitch
visualizationId="visC"
visualizationState={{ type: 'subvisC1' }}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
datasourceMap={datasourceMap}
/>,
{
preloadedState: {
visualization: {
activeId: 'visC',
state: { type: 'subvisC1' },
},
},
}
);
switchTo('subvisC3', component);
switchTo('subvisC3', instance);
expect(switchVisualizationType).toHaveBeenCalledWith('subvisC3', { type: 'subvisC3' });
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'SWITCH_VISUALIZATION',
expect(lensStore.dispatch).toHaveBeenCalledWith({
type: 'lens/switchVisualization',
payload: {
datasourceId: 'testDatasource',
datasourceState: {},
initialState: 'switched',
})
);
expect(frame.removeLayers).not.toHaveBeenCalled();
newVisualizationId: 'visC',
},
});
expect(datasourceMap.testDatasource.removeLayer).not.toHaveBeenCalled();
});
it('should not remove layers and initialize with existing state when switching between subtypes without data', () => {
const dispatch = jest.fn();
it('should not remove layers and initialize with existing state when switching between subtypes without data', async () => {
const frame = mockFrame(['a']);
frame.datasourceLayers.a.getTableSpec = jest.fn().mockReturnValue([]);
const visualizations = mockVisualizations();
visualizations.visC.getSuggestions = jest.fn().mockReturnValue([]);
visualizations.visC.switchVisualizationType = jest.fn(() => 'switched');
const component = mount(
const datasourceMap = mockDatasourceMap();
const { instance } = await mountWithProvider(
<ChartSwitch
visualizationId="visC"
visualizationState={{ type: 'subvisC1' }}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
datasourceMap={datasourceMap}
/>,
{
preloadedState: {
visualization: {
activeId: 'visC',
state: { type: 'subvisC1' },
},
},
}
);
switchTo('subvisC3', component);
switchTo('subvisC3', instance);
expect(visualizations.visC.switchVisualizationType).toHaveBeenCalledWith('subvisC3', {
type: 'subvisC1',
});
expect(frame.removeLayers).not.toHaveBeenCalled();
expect(datasourceMap.testDatasource.removeLayer).not.toHaveBeenCalled();
});
it('should switch to the updated datasource state', () => {
const dispatch = jest.fn();
it('should switch to the updated datasource state', async () => {
const visualizations = mockVisualizations();
const frame = mockFrame(['a', 'b']);
@ -615,31 +681,36 @@ describe('chart_switch', () => {
},
]);
const component = mount(
const { instance, lensStore } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={{}}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={frame}
datasourceMap={datasourceMap}
datasourceStates={mockDatasourceStates()}
/>
/>,
{
preloadedState: {
visualization: {
activeId: 'visA',
state: {},
},
},
}
);
switchTo('visB', component);
switchTo('visB', instance);
expect(dispatch).toHaveBeenCalledWith({
type: 'SWITCH_VISUALIZATION',
newVisualizationId: 'visB',
datasourceId: 'testDatasource',
datasourceState: 'testDatasource suggestion',
initialState: 'suggestion visB',
} as Action);
expect(lensStore.dispatch).toHaveBeenCalledWith({
type: 'lens/switchVisualization',
payload: {
newVisualizationId: 'visB',
datasourceId: 'testDatasource',
datasourceState: 'testDatasource suggestion',
initialState: 'suggestion visB',
},
});
});
it('should ensure the new visualization has the proper subtype', () => {
const dispatch = jest.fn();
it('should ensure the new visualization has the proper subtype', async () => {
const visualizations = mockVisualizations();
const switchVisualizationType = jest.fn(
(visualizationType, state) => `${state} ${visualizationType}`
@ -647,72 +718,85 @@ describe('chart_switch', () => {
visualizations.visB.switchVisualizationType = switchVisualizationType;
const component = mount(
const { instance, lensStore } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={{}}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={mockFrame(['a'])}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
/>,
{
preloadedState: {
visualization: {
activeId: 'visA',
state: {},
},
},
}
);
switchTo('visB', component);
switchTo('visB', instance);
expect(dispatch).toHaveBeenCalledWith({
initialState: 'suggestion visB visB',
newVisualizationId: 'visB',
type: 'SWITCH_VISUALIZATION',
datasourceId: 'testDatasource',
datasourceState: {},
expect(lensStore.dispatch).toHaveBeenCalledWith({
type: 'lens/switchVisualization',
payload: {
initialState: 'suggestion visB visB',
newVisualizationId: 'visB',
datasourceId: 'testDatasource',
datasourceState: {},
},
});
});
it('should use the suggestion that matches the subtype', () => {
const dispatch = jest.fn();
it('should use the suggestion that matches the subtype', async () => {
const visualizations = mockVisualizations();
const switchVisualizationType = jest.fn();
visualizations.visC.switchVisualizationType = switchVisualizationType;
const component = mount(
const { instance } = await mountWithProvider(
<ChartSwitch
visualizationId="visC"
visualizationState={{ type: 'subvisC3' }}
visualizationMap={visualizations}
dispatch={dispatch}
framePublicAPI={mockFrame(['a'])}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
/>,
{
preloadedState: {
visualization: {
activeId: 'visC',
state: { type: 'subvisC3' },
},
},
}
);
switchTo('subvisC1', component);
switchTo('subvisC1', instance);
expect(switchVisualizationType).toHaveBeenCalledWith('subvisC1', {
type: 'subvisC1',
notPrimary: true,
});
});
it('should show all visualization types', () => {
const component = mount(
it('should show all visualization types', async () => {
const { instance } = await mountWithProvider(
<ChartSwitch
visualizationId="visA"
visualizationState={{}}
visualizationMap={mockVisualizations()}
dispatch={jest.fn()}
framePublicAPI={mockFrame(['a', 'b'])}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
/>,
{
preloadedState: {
visualization: {
activeId: 'visA',
state: {},
},
},
}
);
showFlyout(component);
showFlyout(instance);
const allDisplayed = ['visA', 'visB', 'subvisC1', 'subvisC2', 'subvisC3'].every(
(subType) => component.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).length > 0
(subType) => instance.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).length > 0
);
expect(allDisplayed).toBeTruthy();

View file

@ -21,10 +21,16 @@ import {
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { Visualization, FramePublicAPI, Datasource, VisualizationType } from '../../../types';
import { Action } from '../state_management';
import { getSuggestions, switchToSuggestion, Suggestion } from '../suggestion_helpers';
import { trackUiEvent } from '../../../lens_ui_telemetry';
import { ToolbarButton } from '../../../../../../../src/plugins/kibana_react/public';
import {
updateLayer,
updateVisualizationState,
useLensDispatch,
useLensSelector,
} from '../../../state_management';
import { generateId } from '../../../id_generator/id_generator';
interface VisualizationSelection {
visualizationId: string;
@ -38,27 +44,26 @@ interface VisualizationSelection {
}
interface Props {
dispatch: (action: Action) => void;
visualizationMap: Record<string, Visualization>;
visualizationId: string | null;
visualizationState: unknown;
framePublicAPI: FramePublicAPI;
datasourceMap: Record<string, Datasource>;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
}
type SelectableEntry = EuiSelectableOption<{ value: string }>;
function VisualizationSummary(props: Props) {
const visualization = props.visualizationMap[props.visualizationId || ''];
function VisualizationSummary({
visualizationMap,
visualization,
}: {
visualizationMap: Record<string, Visualization>;
visualization: {
activeId: string | null;
state: unknown;
};
}) {
const activeVisualization = visualizationMap[visualization.activeId || ''];
if (!visualization) {
if (!activeVisualization) {
return (
<>
{i18n.translate('xpack.lens.configPanel.selectVisualization', {
@ -68,7 +73,7 @@ function VisualizationSummary(props: Props) {
);
}
const description = visualization.getDescription(props.visualizationState);
const description = activeVisualization.getDescription(visualization.state);
return (
<>
@ -99,6 +104,44 @@ function getCurrentVisualizationId(
export const ChartSwitch = memo(function ChartSwitch(props: Props) {
const [flyoutOpen, setFlyoutOpen] = useState<boolean>(false);
const dispatchLens = useLensDispatch();
const activeDatasourceId = useLensSelector((state) => state.lens.activeDatasourceId);
const visualization = useLensSelector((state) => state.lens.visualization);
const datasourceStates = useLensSelector((state) => state.lens.datasourceStates);
function removeLayers(layerIds: string[]) {
const activeVisualization =
visualization.activeId && props.visualizationMap[visualization.activeId];
if (activeVisualization && activeVisualization.removeLayer && visualization.state) {
dispatchLens(
updateVisualizationState({
visualizationId: activeVisualization.id,
updater: layerIds.reduce(
(acc, layerId) =>
activeVisualization.removeLayer ? activeVisualization.removeLayer(acc, layerId) : acc,
visualization.state
),
})
);
}
layerIds.forEach((layerId) => {
const layerDatasourceId = Object.entries(props.datasourceMap).find(
([datasourceId, datasource]) => {
return (
datasourceStates[datasourceId] &&
datasource.getLayers(datasourceStates[datasourceId].state).includes(layerId)
);
}
)![0];
dispatchLens(
updateLayer({
layerId,
datasourceId: layerDatasourceId,
updater: props.datasourceMap[layerDatasourceId].removeLayer,
})
);
});
}
const commitSelection = (selection: VisualizationSelection) => {
setFlyoutOpen(false);
@ -106,7 +149,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
trackUiEvent(`chart_switch`);
switchToSuggestion(
props.dispatch,
dispatchLens,
{
...selection,
visualizationState: selection.getVisualizationState(),
@ -118,7 +161,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
(!selection.datasourceId && !selection.sameDatasources) ||
selection.dataLoss === 'everything'
) {
props.framePublicAPI.removeLayers(Object.keys(props.framePublicAPI.datasourceLayers));
removeLayers(Object.keys(props.framePublicAPI.datasourceLayers));
}
};
@ -136,16 +179,16 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
);
// Always show the active visualization as a valid selection
if (
props.visualizationId === visualizationId &&
props.visualizationState &&
newVisualization.getVisualizationTypeId(props.visualizationState) === subVisualizationId
visualization.activeId === visualizationId &&
visualization.state &&
newVisualization.getVisualizationTypeId(visualization.state) === subVisualizationId
) {
return {
visualizationId,
subVisualizationId,
dataLoss: 'nothing',
keptLayerIds: Object.keys(props.framePublicAPI.datasourceLayers),
getVisualizationState: () => switchVisType(subVisualizationId, props.visualizationState),
getVisualizationState: () => switchVisType(subVisualizationId, visualization.state),
sameDatasources: true,
};
}
@ -153,6 +196,8 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
const topSuggestion = getTopSuggestion(
props,
visualizationId,
datasourceStates,
visualization,
newVisualization,
subVisualizationId
);
@ -171,6 +216,19 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
dataLoss = 'nothing';
}
function addNewLayer() {
const newLayerId = generateId();
dispatchLens(
updateLayer({
datasourceId: activeDatasourceId!,
layerId: newLayerId,
updater: props.datasourceMap[activeDatasourceId!].insertLayer,
})
);
return newLayerId;
}
return {
visualizationId,
subVisualizationId,
@ -179,29 +237,26 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
? () =>
switchVisType(
subVisualizationId,
newVisualization.initialize(props.framePublicAPI, topSuggestion.visualizationState)
newVisualization.initialize(addNewLayer, topSuggestion.visualizationState)
)
: () => {
return switchVisType(
: () =>
switchVisType(
subVisualizationId,
newVisualization.initialize(
props.framePublicAPI,
props.visualizationId === newVisualization.id
? props.visualizationState
: undefined,
props.visualizationId &&
props.visualizationMap[props.visualizationId].getMainPalette
? props.visualizationMap[props.visualizationId].getMainPalette!(
props.visualizationState
addNewLayer,
visualization.activeId === newVisualization.id ? visualization.state : undefined,
visualization.activeId &&
props.visualizationMap[visualization.activeId].getMainPalette
? props.visualizationMap[visualization.activeId].getMainPalette!(
visualization.state
)
: undefined
)
);
},
),
keptLayerIds: topSuggestion ? topSuggestion.keptLayerIds : [],
datasourceState: topSuggestion ? topSuggestion.datasourceState : undefined,
datasourceId: topSuggestion ? topSuggestion.datasourceId : undefined,
sameDatasources: dataLoss === 'nothing' && props.visualizationId === newVisualization.id,
sameDatasources: dataLoss === 'nothing' && visualization.activeId === newVisualization.id,
};
}
@ -213,8 +268,8 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
return { visualizationTypes: [], visualizationsLookup: {} };
}
const subVisualizationId = getCurrentVisualizationId(
props.visualizationMap[props.visualizationId || ''],
props.visualizationState
props.visualizationMap[visualization.activeId || ''],
visualization.state
);
const lowercasedSearchTerm = searchTerm.toLowerCase();
// reorganize visualizations in groups
@ -351,8 +406,8 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
flyoutOpen,
props.visualizationMap,
props.framePublicAPI,
props.visualizationId,
props.visualizationState,
visualization.activeId,
visualization.state,
searchTerm,
]
);
@ -371,7 +426,10 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
data-test-subj="lnsChartSwitchPopover"
fontWeight="bold"
>
<VisualizationSummary {...props} />
<VisualizationSummary
visualization={visualization}
visualizationMap={props.visualizationMap}
/>
</ToolbarButton>
}
isOpen={flyoutOpen}
@ -402,7 +460,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
}}
options={visualizationTypes}
onChange={(newOptions) => {
const chosenType = newOptions.find(({ checked }) => checked === 'on')!;
const chosenType = newOptions.find(({ checked }) => checked === 'on');
if (!chosenType) {
return;
}
@ -434,21 +492,26 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
function getTopSuggestion(
props: Props,
visualizationId: string,
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>,
visualization: {
activeId: string | null;
state: unknown;
},
newVisualization: Visualization<unknown>,
subVisualizationId?: string
): Suggestion | undefined {
const mainPalette =
props.visualizationId &&
props.visualizationMap[props.visualizationId] &&
props.visualizationMap[props.visualizationId].getMainPalette
? props.visualizationMap[props.visualizationId].getMainPalette!(props.visualizationState)
visualization.activeId &&
props.visualizationMap[visualization.activeId] &&
props.visualizationMap[visualization.activeId].getMainPalette
? props.visualizationMap[visualization.activeId].getMainPalette!(visualization.state)
: undefined;
const unfilteredSuggestions = getSuggestions({
datasourceMap: props.datasourceMap,
datasourceStates: props.datasourceStates,
datasourceStates,
visualizationMap: { [visualizationId]: newVisualization },
activeVisualizationId: props.visualizationId,
visualizationState: props.visualizationState,
activeVisualizationId: visualization.activeId,
visualizationState: visualization.state,
subVisualizationId,
activeData: props.framePublicAPI.activeData,
mainPalette,

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import './workspace_panel_wrapper.scss';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiScreenReaderOnly } from '@elastic/eui';
import { LensState, useLensSelector } from '../../../state_management';
export function WorkspaceTitle() {
const title = useLensSelector((state: LensState) => state.lens.persistedDoc?.title);
return (
<EuiScreenReaderOnly>
<h1 id="lns_ChartTitle" data-test-subj="lns_ChartTitle">
{title ||
i18n.translate('xpack.lens.chartTitle.unsaved', {
defaultMessage: 'Unsaved visualization',
})}
</h1>
</EuiScreenReaderOnly>
);
}

View file

@ -15,7 +15,7 @@ import {
createExpressionRendererMock,
DatasourceMock,
createMockFramePublicAPI,
} from '../../mocks';
} from '../../../mocks';
import { mockDataPlugin, mountWithProvider } from '../../../mocks';
jest.mock('../../../debounced_component', () => {
return {
@ -24,7 +24,6 @@ jest.mock('../../../debounced_component', () => {
});
import { WorkspacePanel } from './workspace_panel';
import { mountWithIntl as mount } from '@kbn/test/jest';
import { ReactWrapper } from 'enzyme';
import { DragDrop, ChildDragDropProvider } from '../../../drag_drop';
import { fromExpression } from '@kbn/interpreter/common';
@ -56,7 +55,6 @@ const defaultProps = {
framePublicAPI: createMockFramePublicAPI(),
activeVisualizationId: 'vis',
visualizationState: {},
dispatch: () => {},
ExpressionRenderer: createExpressionRendererMock(),
core: createCoreStartWithPermissions(),
plugins: {
@ -104,7 +102,8 @@ describe('workspace_panel', () => {
}}
ExpressionRenderer={expressionRendererMock}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2);
@ -119,7 +118,8 @@ describe('workspace_panel', () => {
vis: { ...mockVisualization, toExpression: () => null },
}}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -135,7 +135,8 @@ describe('workspace_panel', () => {
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -169,7 +170,7 @@ describe('workspace_panel', () => {
}}
ExpressionRenderer={expressionRendererMock}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -209,7 +210,8 @@ describe('workspace_panel', () => {
ExpressionRenderer={expressionRendererMock}
plugins={{ ...props.plugins, uiActions: uiActionsMock }}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -229,7 +231,6 @@ describe('workspace_panel', () => {
};
mockDatasource.toExpression.mockReturnValue('datasource');
mockDatasource.getLayers.mockReturnValue(['first']);
const dispatch = jest.fn();
const mounted = await mountWithProvider(
<WorkspacePanel
@ -247,10 +248,10 @@ describe('workspace_panel', () => {
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
dispatch={dispatch}
ExpressionRenderer={expressionRendererMock}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -261,8 +262,8 @@ describe('workspace_panel', () => {
onData(undefined, { tables: { tables: tableData } });
expect(mounted.lensStore.dispatch).toHaveBeenCalledWith({
type: 'app/onActiveDataChange',
payload: { activeData: tableData },
type: 'lens/onActiveDataChange',
payload: tableData,
});
});
@ -302,7 +303,8 @@ describe('workspace_panel', () => {
}}
ExpressionRenderer={expressionRendererMock}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -377,7 +379,8 @@ describe('workspace_panel', () => {
}}
ExpressionRenderer={expressionRendererMock}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
});
@ -430,7 +433,8 @@ describe('workspace_panel', () => {
}}
ExpressionRenderer={expressionRendererMock}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
});
@ -481,7 +485,8 @@ describe('workspace_panel', () => {
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -520,7 +525,8 @@ describe('workspace_panel', () => {
management: { kibana: { indexPatterns: true } },
})}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -559,7 +565,8 @@ describe('workspace_panel', () => {
management: { kibana: { indexPatterns: false } },
})}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -595,7 +602,8 @@ describe('workspace_panel', () => {
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -632,7 +640,8 @@ describe('workspace_panel', () => {
vis: mockVisualization,
}}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -671,7 +680,8 @@ describe('workspace_panel', () => {
vis: mockVisualization,
}}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -707,7 +717,8 @@ describe('workspace_panel', () => {
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
@ -742,7 +753,8 @@ describe('workspace_panel', () => {
}}
ExpressionRenderer={expressionRendererMock}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
});
@ -783,7 +795,8 @@ describe('workspace_panel', () => {
}}
ExpressionRenderer={expressionRendererMock}
/>,
defaultProps.plugins.data
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
});
@ -805,7 +818,6 @@ describe('workspace_panel', () => {
});
describe('suggestions from dropping in workspace panel', () => {
let mockDispatch: jest.Mock;
let mockGetSuggestionForField: jest.Mock;
let frame: jest.Mocked<FramePublicAPI>;
@ -813,12 +825,11 @@ describe('workspace_panel', () => {
beforeEach(() => {
frame = createMockFramePublicAPI();
mockDispatch = jest.fn();
mockGetSuggestionForField = jest.fn();
});
function initComponent(draggingContext = draggedField) {
instance = mount(
async function initComponent(draggingContext = draggedField) {
const mounted = await mountWithProvider(
<ChildDragDropProvider
dragging={draggingContext}
setDragging={() => {}}
@ -846,11 +857,12 @@ describe('workspace_panel', () => {
vis: mockVisualization,
vis2: mockVisualization2,
}}
dispatch={mockDispatch}
getSuggestionForField={mockGetSuggestionForField}
/>
</ChildDragDropProvider>
);
instance = mounted.instance;
return mounted;
}
it('should immediately transition if exactly one suggestion is returned', async () => {
@ -860,32 +872,34 @@ describe('workspace_panel', () => {
datasourceId: 'mock',
visualizationState: {},
});
initComponent();
const { lensStore } = await initComponent();
instance.find(DragDrop).prop('onDrop')!(draggedField, 'field_replace');
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SWITCH_VISUALIZATION',
newVisualizationId: 'vis',
initialState: {},
datasourceState: {},
datasourceId: 'mock',
expect(lensStore.dispatch).toHaveBeenCalledWith({
type: 'lens/switchVisualization',
payload: {
newVisualizationId: 'vis',
initialState: {},
datasourceState: {},
datasourceId: 'mock',
},
});
});
it('should allow to drop if there are suggestions', () => {
it('should allow to drop if there are suggestions', async () => {
mockGetSuggestionForField.mockReturnValue({
visualizationId: 'vis',
datasourceState: {},
datasourceId: 'mock',
visualizationState: {},
});
initComponent();
await initComponent();
expect(instance.find(DragDrop).prop('dropTypes')).toBeTruthy();
});
it('should refuse to drop if there are no suggestions', () => {
initComponent();
it('should refuse to drop if there are no suggestions', async () => {
await initComponent();
expect(instance.find(DragDrop).prop('dropType')).toBeFalsy();
});
});

View file

@ -33,7 +33,6 @@ import {
ExpressionRenderError,
ReactExpressionRendererType,
} from '../../../../../../../src/plugins/expressions/public';
import { Action } from '../state_management';
import {
Datasource,
Visualization,
@ -46,17 +45,20 @@ import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop';
import { Suggestion, switchToSuggestion } from '../suggestion_helpers';
import { buildExpression } from '../expression_helpers';
import { trackUiEvent } from '../../../lens_ui_telemetry';
import {
UiActionsStart,
VisualizeFieldContext,
} from '../../../../../../../src/plugins/ui_actions/public';
import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public';
import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public';
import { WorkspacePanelWrapper } from './workspace_panel_wrapper';
import { DropIllustration } from '../../../assets/drop_illustration';
import { getOriginalRequestErrorMessages } from '../../error_helper';
import { getMissingIndexPattern, validateDatasourceAndVisualization } from '../state_helpers';
import { DefaultInspectorAdapters } from '../../../../../../../src/plugins/expressions/common';
import { onActiveDataChange, useLensDispatch } from '../../../state_management';
import {
onActiveDataChange,
useLensDispatch,
updateVisualizationState,
updateDatasourceState,
setSaveable,
} from '../../../state_management';
export interface WorkspacePanelProps {
activeVisualizationId: string | null;
@ -72,12 +74,9 @@ export interface WorkspacePanelProps {
}
>;
framePublicAPI: FramePublicAPI;
dispatch: (action: Action) => void;
ExpressionRenderer: ReactExpressionRendererType;
core: CoreStart;
plugins: { uiActions?: UiActionsStart; data: DataPublicPluginStart };
title?: string;
visualizeTriggerFieldContext?: VisualizeFieldContext;
getSuggestionForField: (field: DragDropIdentifier) => Suggestion | undefined;
isFullscreen: boolean;
}
@ -128,17 +127,15 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
datasourceMap,
datasourceStates,
framePublicAPI,
dispatch,
core,
plugins,
ExpressionRenderer: ExpressionRendererComponent,
title,
visualizeTriggerFieldContext,
suggestionForDraggedField,
isFullscreen,
}: Omit<WorkspacePanelProps, 'getSuggestionForField'> & {
suggestionForDraggedField: Suggestion | undefined;
}) {
const dispatchLens = useLensDispatch();
const [localState, setLocalState] = useState<WorkspaceState>({
expressionBuildError: undefined,
expandError: false,
@ -196,6 +193,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
datasourceStates,
datasourceLayers: framePublicAPI.datasourceLayers,
});
if (ast) {
// expression has to be turned into a string for dirty checking - if the ast is rebuilt,
// turning it into a string will make sure the expression renderer only re-renders if the
@ -233,6 +231,14 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
);
const expressionExists = Boolean(expression);
const hasLoaded = Boolean(
activeVisualization && visualizationState && datasourceMap && datasourceStates
);
useEffect(() => {
if (hasLoaded) {
dispatchLens(setSaveable(expressionExists));
}
}, [hasLoaded, expressionExists, dispatchLens]);
const onEvent = useCallback(
(event: ExpressionRendererEvent) => {
@ -251,14 +257,15 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
});
}
if (isLensEditEvent(event) && activeVisualization?.onEditAction) {
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
updater: (oldState: unknown) => activeVisualization.onEditAction!(oldState, event),
});
dispatchLens(
updateVisualizationState({
visualizationId: activeVisualization.id,
updater: (oldState: unknown) => activeVisualization.onEditAction!(oldState, event),
})
);
}
},
[plugins.uiActions, dispatch, activeVisualization]
[plugins.uiActions, activeVisualization, dispatchLens]
);
useEffect(() => {
@ -275,9 +282,9 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
if (suggestionForDraggedField) {
trackUiEvent('drop_onto_workspace');
trackUiEvent(expressionExists ? 'drop_non_empty' : 'drop_empty');
switchToSuggestion(dispatch, suggestionForDraggedField, 'SWITCH_VISUALIZATION');
switchToSuggestion(dispatchLens, suggestionForDraggedField, 'SWITCH_VISUALIZATION');
}
}, [suggestionForDraggedField, expressionExists, dispatch]);
}, [suggestionForDraggedField, expressionExists, dispatchLens]);
const renderEmptyWorkspace = () => {
return (
@ -327,9 +334,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
};
const renderVisualization = () => {
// we don't want to render the emptyWorkspace on visualizing field from Discover
// as it is specific for the drag and drop functionality and can confuse the users
if (expression === null && !visualizeTriggerFieldContext) {
if (expression === null) {
return renderEmptyWorkspace();
}
return (
@ -337,7 +342,6 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
expression={expression}
framePublicAPI={framePublicAPI}
timefilter={plugins.data.query.timefilter.timefilter}
dispatch={dispatch}
onEvent={onEvent}
setLocalState={setLocalState}
localState={{ ...localState, configurationValidationError, missingRefsErrors }}
@ -387,9 +391,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
return (
<WorkspacePanelWrapper
title={title}
framePublicAPI={framePublicAPI}
dispatch={dispatch}
visualizationState={visualizationState}
visualizationId={activeVisualizationId}
datasourceStates={datasourceStates}
@ -410,7 +412,6 @@ export const VisualizationWrapper = ({
setLocalState,
localState,
ExpressionRendererComponent,
dispatch,
application,
activeDatasourceId,
}: {
@ -418,7 +419,6 @@ export const VisualizationWrapper = ({
framePublicAPI: FramePublicAPI;
timefilter: TimefilterContract;
onEvent: (event: ExpressionRendererEvent) => void;
dispatch: (action: Action) => void;
setLocalState: (dispatch: (prevState: WorkspaceState) => WorkspaceState) => void;
localState: WorkspaceState & {
configurationValidationError?: Array<{
@ -454,7 +454,7 @@ export const VisualizationWrapper = ({
const onData$ = useCallback(
(data: unknown, inspectorAdapters?: Partial<DefaultInspectorAdapters>) => {
if (inspectorAdapters && inspectorAdapters.tables) {
dispatchLens(onActiveDataChange({ activeData: { ...inspectorAdapters.tables.tables } }));
dispatchLens(onActiveDataChange({ ...inspectorAdapters.tables.tables }));
}
},
[dispatchLens]
@ -480,11 +480,12 @@ export const VisualizationWrapper = ({
data-test-subj="errorFixAction"
onClick={async () => {
const newState = await validationError.fixAction?.newState(framePublicAPI);
dispatch({
type: 'UPDATE_DATASOURCE_STATE',
datasourceId: activeDatasourceId,
updater: newState,
});
dispatchLens(
updateDatasourceState({
updater: newState,
datasourceId: activeDatasourceId,
})
);
}}
>
{validationError.fixAction.label}

View file

@ -7,30 +7,23 @@
import React from 'react';
import { Visualization } from '../../../types';
import { createMockVisualization, createMockFramePublicAPI, FrameMock } from '../../mocks';
import { mountWithIntl as mount } from '@kbn/test/jest';
import { ReactWrapper } from 'enzyme';
import { WorkspacePanelWrapper, WorkspacePanelWrapperProps } from './workspace_panel_wrapper';
import { createMockVisualization, createMockFramePublicAPI, FrameMock } from '../../../mocks';
import { WorkspacePanelWrapper } from './workspace_panel_wrapper';
import { mountWithProvider } from '../../../mocks';
describe('workspace_panel_wrapper', () => {
let mockVisualization: jest.Mocked<Visualization>;
let mockFrameAPI: FrameMock;
let instance: ReactWrapper<WorkspacePanelWrapperProps>;
beforeEach(() => {
mockVisualization = createMockVisualization();
mockFrameAPI = createMockFramePublicAPI();
});
afterEach(() => {
instance.unmount();
});
it('should render its children', () => {
it('should render its children', async () => {
const MyChild = () => <span>The child elements</span>;
instance = mount(
const { instance } = await mountWithProvider(
<WorkspacePanelWrapper
dispatch={jest.fn()}
framePublicAPI={mockFrameAPI}
visualizationState={{}}
visualizationId="myVis"
@ -46,12 +39,11 @@ describe('workspace_panel_wrapper', () => {
expect(instance.find(MyChild)).toHaveLength(1);
});
it('should call the toolbar renderer if provided', () => {
it('should call the toolbar renderer if provided', async () => {
const renderToolbarMock = jest.fn();
const visState = { internalState: 123 };
instance = mount(
await mountWithProvider(
<WorkspacePanelWrapper
dispatch={jest.fn()}
framePublicAPI={mockFrameAPI}
visualizationState={visState}
children={<span />}

View file

@ -8,21 +8,19 @@
import './workspace_panel_wrapper.scss';
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiPageContent, EuiFlexGroup, EuiFlexItem, EuiScreenReaderOnly } from '@elastic/eui';
import { EuiPageContent, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import classNames from 'classnames';
import { Datasource, FramePublicAPI, Visualization } from '../../../types';
import { NativeRenderer } from '../../../native_renderer';
import { Action } from '../state_management';
import { ChartSwitch } from './chart_switch';
import { WarningsPopover } from './warnings_popover';
import { useLensDispatch, updateVisualizationState } from '../../../state_management';
import { WorkspaceTitle } from './title';
export interface WorkspacePanelWrapperProps {
children: React.ReactNode | React.ReactNode[];
framePublicAPI: FramePublicAPI;
visualizationState: unknown;
dispatch: (action: Action) => void;
title?: string;
visualizationMap: Record<string, Visualization>;
visualizationId: string | null;
datasourceMap: Record<string, Datasource>;
@ -40,28 +38,29 @@ export function WorkspacePanelWrapper({
children,
framePublicAPI,
visualizationState,
dispatch,
title,
visualizationId,
visualizationMap,
datasourceMap,
datasourceStates,
isFullscreen,
}: WorkspacePanelWrapperProps) {
const dispatchLens = useLensDispatch();
const activeVisualization = visualizationId ? visualizationMap[visualizationId] : null;
const setVisualizationState = useCallback(
(newState: unknown) => {
if (!activeVisualization) {
return;
}
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
updater: newState,
clearStagedPreview: false,
});
dispatchLens(
updateVisualizationState({
visualizationId: activeVisualization.id,
updater: newState,
clearStagedPreview: false,
})
);
},
[dispatch, activeVisualization]
[dispatchLens, activeVisualization]
);
const warningMessages: React.ReactNode[] = [];
if (activeVisualization?.getWarningMessages) {
@ -101,11 +100,7 @@ export function WorkspacePanelWrapper({
<ChartSwitch
data-test-subj="lnsChartSwitcher"
visualizationMap={visualizationMap}
visualizationId={visualizationId}
visualizationState={visualizationState}
datasourceMap={datasourceMap}
datasourceStates={datasourceStates}
dispatch={dispatch}
framePublicAPI={framePublicAPI}
/>
</EuiFlexItem>
@ -136,14 +131,7 @@ export function WorkspacePanelWrapper({
'lnsWorkspacePanelWrapper--fullscreen': isFullscreen,
})}
>
<EuiScreenReaderOnly>
<h1 id="lns_ChartTitle" data-test-subj="lns_ChartTitle">
{title ||
i18n.translate('xpack.lens.chartTitle.unsaved', {
defaultMessage: 'Unsaved visualization',
})}
</h1>
</EuiScreenReaderOnly>
<WorkspaceTitle />
{children}
</EuiPageContent>
</>

View file

@ -5,105 +5,14 @@
* 2.0.
*/
import React from 'react';
import { PaletteDefinition } from 'src/plugins/charts/public';
import {
ReactExpressionRendererProps,
ExpressionsSetup,
ExpressionsStart,
} from '../../../../../src/plugins/expressions/public';
import { ExpressionsSetup, ExpressionsStart } from '../../../../../src/plugins/expressions/public';
import { embeddablePluginMock } from '../../../../../src/plugins/embeddable/public/mocks';
import { expressionsPluginMock } from '../../../../../src/plugins/expressions/public/mocks';
import { DatasourcePublicAPI, FramePublicAPI, Datasource, Visualization } from '../types';
import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './service';
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
export function createMockVisualization(): jest.Mocked<Visualization> {
return {
id: 'TEST_VIS',
clearLayer: jest.fn((state, _layerId) => state),
removeLayer: jest.fn(),
getLayerIds: jest.fn((_state) => ['layer1']),
visualizationTypes: [
{
icon: 'empty',
id: 'TEST_VIS',
label: 'TEST',
groupLabel: 'TEST_VISGroup',
},
],
getVisualizationTypeId: jest.fn((_state) => 'empty'),
getDescription: jest.fn((_state) => ({ label: '' })),
switchVisualizationType: jest.fn((_, x) => x),
getSuggestions: jest.fn((_options) => []),
initialize: jest.fn((_frame, _state?) => ({})),
getConfiguration: jest.fn((props) => ({
groups: [
{
groupId: 'a',
groupLabel: 'a',
layerId: 'layer1',
supportsMoreColumns: true,
accessors: [],
filterOperations: jest.fn(() => true),
dataTestSubj: 'mockVisA',
},
],
})),
toExpression: jest.fn((_state, _frame) => null),
toPreviewExpression: jest.fn((_state, _frame) => null),
setDimension: jest.fn(),
removeDimension: jest.fn(),
getErrorMessages: jest.fn((_state) => undefined),
renderDimensionEditor: jest.fn(),
};
}
export type DatasourceMock = jest.Mocked<Datasource> & {
publicAPIMock: jest.Mocked<DatasourcePublicAPI>;
};
export function createMockDatasource(id: string): DatasourceMock {
const publicAPIMock: jest.Mocked<DatasourcePublicAPI> = {
datasourceId: id,
getTableSpec: jest.fn(() => []),
getOperationForColumnId: jest.fn(),
};
return {
id: 'mockindexpattern',
clearLayer: jest.fn((state, _layerId) => state),
getDatasourceSuggestionsForField: jest.fn((_state, _item) => []),
getDatasourceSuggestionsForVisualizeField: jest.fn((_state, _indexpatternId, _fieldName) => []),
getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []),
getPersistableState: jest.fn((x) => ({ state: x, savedObjectReferences: [] })),
getPublicAPI: jest.fn().mockReturnValue(publicAPIMock),
initialize: jest.fn((_state?) => Promise.resolve()),
renderDataPanel: jest.fn(),
renderLayerPanel: jest.fn(),
toExpression: jest.fn((_frame, _state) => null),
insertLayer: jest.fn((_state, _newLayerId) => {}),
removeLayer: jest.fn((_state, _layerId) => {}),
removeColumn: jest.fn((props) => {}),
getLayers: jest.fn((_state) => []),
uniqueLabels: jest.fn((_state) => ({})),
renderDimensionTrigger: jest.fn(),
renderDimensionEditor: jest.fn(),
getDropProps: jest.fn(),
onDrop: jest.fn(),
// this is an additional property which doesn't exist on real datasources
// but can be used to validate whether specific API mock functions are called
publicAPIMock,
getErrorMessages: jest.fn((_state) => undefined),
checkIntegrity: jest.fn((_state) => []),
};
}
export type FrameMock = jest.Mocked<FramePublicAPI>;
export function createMockPaletteDefinition(): jest.Mocked<PaletteDefinition> {
return {
getCategoricalColors: jest.fn((_) => ['#ff0000', '#00ff00']),
@ -123,23 +32,6 @@ export function createMockPaletteDefinition(): jest.Mocked<PaletteDefinition> {
};
}
export function createMockFramePublicAPI(): FrameMock {
const palette = createMockPaletteDefinition();
return {
datasourceLayers: {},
addNewLayer: jest.fn(() => ''),
removeLayers: jest.fn(),
dateRange: { fromDate: 'now-7d', toDate: 'now' },
query: { query: '', language: 'lucene' },
filters: [],
availablePalettes: {
get: () => palette,
getAll: () => [palette],
},
searchSessionId: 'sessionId',
};
}
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
export type MockedSetupDependencies = Omit<EditorFrameSetupPlugins, 'expressions'> & {
@ -150,13 +42,6 @@ export type MockedStartDependencies = Omit<EditorFrameStartPlugins, 'expressions
expressions: jest.Mocked<ExpressionsStart>;
};
export function createExpressionRendererMock(): jest.Mock<
React.ReactElement,
[ReactExpressionRendererProps]
> {
return jest.fn((_) => <span />);
}
export function createMockSetupDependencies() {
return ({
data: dataPluginMock.createSetupContract(),

View file

@ -105,27 +105,25 @@ export class EditorFrameService {
]);
const { EditorFrame } = await import('../async_services');
const palettes = await plugins.charts.palettes.getPalettes();
return {
EditorFrameContainer: ({ onError, showNoDataPopover, initialContext }) => {
EditorFrameContainer: ({ showNoDataPopover }) => {
return (
<div className="lnsApp__frame">
<EditorFrame
data-test-subj="lnsEditorFrame"
onError={onError}
datasourceMap={resolvedDatasources}
visualizationMap={resolvedVisualizations}
core={core}
plugins={plugins}
ExpressionRenderer={plugins.expressions.ReactExpressionRenderer}
palettes={palettes}
showNoDataPopover={showNoDataPopover}
initialContext={initialContext}
datasourceMap={resolvedDatasources}
visualizationMap={resolvedVisualizations}
ExpressionRenderer={plugins.expressions.ReactExpressionRenderer}
/>
</div>
);
},
datasourceMap: resolvedDatasources,
visualizationMap: resolvedVisualizations,
};
};

View file

@ -10,7 +10,7 @@ import {
getHeatmapVisualization,
isCellValueSupported,
} from './visualization';
import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks';
import { createMockDatasource, createMockFramePublicAPI } from '../mocks';
import {
CHART_SHAPES,
FUNCTION_NAME,
@ -49,8 +49,8 @@ describe('heatmap', () => {
describe('#intialize', () => {
test('returns a default state', () => {
expect(getHeatmapVisualization({}).initialize(frame)).toEqual({
layerId: '',
expect(getHeatmapVisualization({}).initialize(() => 'l1')).toEqual({
layerId: 'l1',
title: 'Empty Heatmap chart',
shape: CHART_SHAPES.HEATMAP,
legend: {
@ -68,7 +68,9 @@ describe('heatmap', () => {
});
test('returns persisted state', () => {
expect(getHeatmapVisualization({}).initialize(frame, exampleState())).toEqual(exampleState());
expect(getHeatmapVisualization({}).initialize(() => 'test-layer', exampleState())).toEqual(
exampleState()
);
});
});

View file

@ -119,10 +119,10 @@ export const getHeatmapVisualization = ({
return CHART_NAMES.heatmap;
},
initialize(frame, state, mainPalette) {
initialize(addNewLayer, state, mainPalette) {
return (
state || {
layerId: frame.addNewLayer(),
layerId: addNewLayer(),
title: 'Empty Heatmap chart',
...getInitialState(),
}

View file

@ -446,10 +446,13 @@ export async function syncExistingFields({
isFirstExistenceFetch: false,
existenceFetchFailed: false,
existenceFetchTimeout: false,
existingFields: emptinessInfo.reduce((acc, info) => {
acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames);
return acc;
}, state.existingFields),
existingFields: emptinessInfo.reduce(
(acc, info) => {
acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames);
return acc;
},
{ ...state.existingFields }
),
}));
} catch (e) {
// show all fields as available if fetch failed or timed out
@ -457,10 +460,13 @@ export async function syncExistingFields({
...state,
existenceFetchFailed: e.res?.status !== 408,
existenceFetchTimeout: e.res?.status === 408,
existingFields: indexPatterns.reduce((acc, pattern) => {
acc[pattern.title] = booleanMap(pattern.fields.map((field) => field.name));
return acc;
}, state.existingFields),
existingFields: indexPatterns.reduce(
(acc, pattern) => {
acc[pattern.title] = booleanMap(pattern.fields.map((field) => field.name));
return acc;
},
{ ...state.existingFields }
),
}));
}
}

View file

@ -7,7 +7,7 @@
import { metricVisualization } from './visualization';
import { MetricState } from './types';
import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks';
import { createMockDatasource, createMockFramePublicAPI } from '../mocks';
import { generateId } from '../id_generator';
import { DatasourcePublicAPI, FramePublicAPI } from '../types';
@ -23,7 +23,6 @@ function exampleState(): MetricState {
function mockFrame(): FramePublicAPI {
return {
...createMockFramePublicAPI(),
addNewLayer: () => 'l42',
datasourceLayers: {
l1: createMockDatasource('l1').publicAPIMock,
l42: createMockDatasource('l42').publicAPIMock,
@ -35,19 +34,19 @@ describe('metric_visualization', () => {
describe('#initialize', () => {
it('loads default state', () => {
(generateId as jest.Mock).mockReturnValueOnce('test-id1');
const initialState = metricVisualization.initialize(mockFrame());
const initialState = metricVisualization.initialize(() => 'test-id1');
expect(initialState.accessor).not.toBeDefined();
expect(initialState).toMatchInlineSnapshot(`
Object {
"accessor": undefined,
"layerId": "l42",
}
`);
Object {
"accessor": undefined,
"layerId": "test-id1",
}
`);
});
it('loads from persisted state', () => {
expect(metricVisualization.initialize(mockFrame(), exampleState())).toEqual(exampleState());
expect(metricVisualization.initialize(() => 'l1', exampleState())).toEqual(exampleState());
});
});

View file

@ -85,10 +85,10 @@ export const metricVisualization: Visualization<MetricState> = {
getSuggestions,
initialize(frame, state) {
initialize(addNewLayer, state) {
return (
state || {
layerId: frame.addNewLayer(),
layerId: addNewLayer(),
accessor: undefined,
}
);

View file

@ -15,6 +15,7 @@ import { coreMock } from 'src/core/public/mocks';
import moment from 'moment';
import { Provider } from 'react-redux';
import { act } from 'react-dom/test-utils';
import { ReactExpressionRendererProps } from 'src/plugins/expressions/public';
import { LensPublicStart } from '.';
import { visualizationTypes } from './xy_visualization/types';
import { navigationPluginMock } from '../../../../src/plugins/navigation/public/mocks';
@ -37,6 +38,111 @@ import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/publ
import { makeConfigureStore, getPreloadedState, LensAppState } from './state_management/index';
import { getResolvedDateRange } from './utils';
import { presentationUtilPluginMock } from '../../../../src/plugins/presentation_util/public/mocks';
import { DatasourcePublicAPI, Datasource, Visualization, FramePublicAPI } from './types';
export function createMockVisualization(): jest.Mocked<Visualization> {
return {
id: 'TEST_VIS',
clearLayer: jest.fn((state, _layerId) => state),
removeLayer: jest.fn(),
getLayerIds: jest.fn((_state) => ['layer1']),
visualizationTypes: [
{
icon: 'empty',
id: 'TEST_VIS',
label: 'TEST',
groupLabel: 'TEST_VISGroup',
},
],
getVisualizationTypeId: jest.fn((_state) => 'empty'),
getDescription: jest.fn((_state) => ({ label: '' })),
switchVisualizationType: jest.fn((_, x) => x),
getSuggestions: jest.fn((_options) => []),
initialize: jest.fn((_frame, _state?) => ({})),
getConfiguration: jest.fn((props) => ({
groups: [
{
groupId: 'a',
groupLabel: 'a',
layerId: 'layer1',
supportsMoreColumns: true,
accessors: [],
filterOperations: jest.fn(() => true),
dataTestSubj: 'mockVisA',
},
],
})),
toExpression: jest.fn((_state, _frame) => null),
toPreviewExpression: jest.fn((_state, _frame) => null),
setDimension: jest.fn(),
removeDimension: jest.fn(),
getErrorMessages: jest.fn((_state) => undefined),
renderDimensionEditor: jest.fn(),
};
}
export type DatasourceMock = jest.Mocked<Datasource> & {
publicAPIMock: jest.Mocked<DatasourcePublicAPI>;
};
export function createMockDatasource(id: string): DatasourceMock {
const publicAPIMock: jest.Mocked<DatasourcePublicAPI> = {
datasourceId: id,
getTableSpec: jest.fn(() => []),
getOperationForColumnId: jest.fn(),
};
return {
id: 'mockindexpattern',
clearLayer: jest.fn((state, _layerId) => state),
getDatasourceSuggestionsForField: jest.fn((_state, _item) => []),
getDatasourceSuggestionsForVisualizeField: jest.fn((_state, _indexpatternId, _fieldName) => []),
getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []),
getPersistableState: jest.fn((x) => ({
state: x,
savedObjectReferences: [{ type: 'index-pattern', id: 'mockip', name: 'mockip' }],
})),
getPublicAPI: jest.fn().mockReturnValue(publicAPIMock),
initialize: jest.fn((_state?) => Promise.resolve()),
renderDataPanel: jest.fn(),
renderLayerPanel: jest.fn(),
toExpression: jest.fn((_frame, _state) => null),
insertLayer: jest.fn((_state, _newLayerId) => {}),
removeLayer: jest.fn((_state, _layerId) => {}),
removeColumn: jest.fn((props) => {}),
getLayers: jest.fn((_state) => []),
uniqueLabels: jest.fn((_state) => ({})),
renderDimensionTrigger: jest.fn(),
renderDimensionEditor: jest.fn(),
getDropProps: jest.fn(),
onDrop: jest.fn(),
// this is an additional property which doesn't exist on real datasources
// but can be used to validate whether specific API mock functions are called
publicAPIMock,
getErrorMessages: jest.fn((_state) => undefined),
checkIntegrity: jest.fn((_state) => []),
};
}
export function createExpressionRendererMock(): jest.Mock<
React.ReactElement,
[ReactExpressionRendererProps]
> {
return jest.fn((_) => <span />);
}
export type FrameMock = jest.Mocked<FramePublicAPI>;
export function createMockFramePublicAPI(): FrameMock {
return {
datasourceLayers: {},
dateRange: { fromDate: 'now-7d', toDate: 'now' },
query: { query: '', language: 'lucene' },
filters: [],
searchSessionId: 'sessionId',
};
}
export type Start = jest.Mocked<LensPublicStart>;
@ -66,6 +172,9 @@ export const defaultDoc = ({
state: {
query: 'kuery',
filters: [{ query: { match_phrase: { src: 'test' } } }],
datasourceStates: {
testDatasource: 'datasource',
},
},
references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }],
} as unknown) as Document;
@ -257,20 +366,48 @@ export function makeDefaultServices(
};
}
export function mockLensStore({
export const defaultState = {
searchSessionId: 'sessionId-1',
filters: [],
query: { language: 'lucene', query: '' },
resolvedDateRange: { fromDate: '2021-01-10T04:00:00.000Z', toDate: '2021-01-10T08:00:00.000Z' },
isFullscreenDatasource: false,
isSaveable: false,
isLoading: false,
isLinkedToOriginatingApp: false,
activeDatasourceId: 'testDatasource',
visualization: {
state: {},
activeId: 'testVis',
},
datasourceStates: {
testDatasource: {
isLoading: false,
state: '',
},
},
};
export function makeLensStore({
data,
storePreloadedState,
preloadedState,
dispatch,
}: {
data: DataPublicPluginStart;
storePreloadedState?: Partial<LensAppState>;
data?: DataPublicPluginStart;
preloadedState?: Partial<LensAppState>;
dispatch?: jest.Mock;
}) {
if (!data) {
data = mockDataPlugin();
}
const lensStore = makeConfigureStore(
getPreloadedState({
...defaultState,
searchSessionId: data.search.session.start(),
query: data.query.queryString.getQuery(),
filters: data.query.filterManager.getGlobalFilters(),
searchSessionId: data.search.session.start(),
resolvedDateRange: getResolvedDateRange(data.query.timefilter.timefilter),
...storePreloadedState,
...preloadedState,
}),
{
data,
@ -278,36 +415,52 @@ export function mockLensStore({
);
const origDispatch = lensStore.dispatch;
lensStore.dispatch = jest.fn(origDispatch);
lensStore.dispatch = jest.fn(dispatch || origDispatch);
return lensStore;
}
export const mountWithProvider = async (
component: React.ReactElement,
data: DataPublicPluginStart,
storePreloadedState?: Partial<LensAppState>,
extraWrappingComponent?: React.FC<{
children: React.ReactNode;
}>
store?: {
data?: DataPublicPluginStart;
preloadedState?: Partial<LensAppState>;
dispatch?: jest.Mock;
},
options?: {
wrappingComponent?: React.FC<{
children: React.ReactNode;
}>;
attachTo?: HTMLElement;
}
) => {
const lensStore = mockLensStore({ data, storePreloadedState });
const lensStore = makeLensStore(store || {});
const wrappingComponent: React.FC<{
let wrappingComponent: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
if (extraWrappingComponent) {
return extraWrappingComponent({
children: <Provider store={lensStore}>{children}</Provider>,
});
}
return <Provider store={lensStore}>{children}</Provider>;
}> = ({ children }) => <Provider store={lensStore}>{children}</Provider>;
let restOptions: {
attachTo?: HTMLElement | undefined;
};
if (options) {
const { wrappingComponent: _wrappingComponent, ...rest } = options;
restOptions = rest;
if (_wrappingComponent) {
wrappingComponent = ({ children }) => {
return _wrappingComponent({
children: <Provider store={lensStore}>{children}</Provider>,
});
};
}
}
let instance: ReactWrapper = {} as ReactWrapper;
await act(async () => {
instance = mount(component, ({
wrappingComponent,
...restOptions,
} as unknown) as ReactWrapper);
});
return { instance, lensStore };

View file

@ -91,11 +91,11 @@ export const getPieVisualization = ({
shape: visualizationTypeId as PieVisualizationState['shape'],
}),
initialize(frame, state, mainPalette) {
initialize(addNewLayer, state, mainPalette) {
return (
state || {
shape: 'donut',
layers: [newLayerState(frame.addNewLayer())],
layers: [newLayerState(addNewLayer())],
palette: mainPalette,
}
);

View file

@ -1,55 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { LensAppState } from './types';
export const initialState: LensAppState = {
searchSessionId: '',
filters: [],
query: { language: 'kuery', query: '' },
resolvedDateRange: { fromDate: '', toDate: '' },
indexPatternsForTopNav: [],
isSaveable: false,
isAppLoading: false,
isLinkedToOriginatingApp: false,
};
export const appSlice = createSlice({
name: 'app',
initialState,
reducers: {
setState: (state, { payload }: PayloadAction<Partial<LensAppState>>) => {
return {
...state,
...payload,
};
},
onChangeFromEditorFrame: (state, { payload }: PayloadAction<Partial<LensAppState>>) => {
return {
...state,
...payload,
};
},
onActiveDataChange: (state, { payload }: PayloadAction<Partial<LensAppState>>) => {
if (!isEqual(state.activeData, payload?.activeData)) {
return {
...state,
...payload,
};
}
return state;
},
navigateAway: (state) => state,
},
});
export const reducer = {
app: appSlice.reducer,
};

View file

@ -27,7 +27,7 @@ export const externalContextMiddleware = (data: DataPublicPluginStart) => (
store.dispatch
);
return (next: Dispatch) => (action: PayloadAction<Partial<LensAppState>>) => {
if (action.type === 'app/navigateAway') {
if (action.type === 'lens/navigateAway') {
unsubscribeFromExternalContext();
}
next(action);
@ -44,7 +44,7 @@ function subscribeToExternalContext(
const dispatchFromExternal = (searchSessionId = search.session.start()) => {
const globalFilters = filterManager.getFilters();
const filters = isEqual(getState().app.filters, globalFilters)
const filters = isEqual(getState().lens.filters, globalFilters)
? null
: { filters: globalFilters };
dispatch(
@ -64,7 +64,7 @@ function subscribeToExternalContext(
.pipe(delay(0))
// then update if it didn't get updated yet
.subscribe((newSessionId?: string) => {
if (newSessionId && getState().app.searchSessionId !== newSessionId) {
if (newSessionId && getState().lens.searchSessionId !== newSessionId) {
debounceDispatchFromExternal(newSessionId);
}
});

View file

@ -8,8 +8,9 @@
import { configureStore, DeepPartial, getDefaultMiddleware } from '@reduxjs/toolkit';
import logger from 'redux-logger';
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import { appSlice, initialState } from './app_slice';
import { lensSlice, initialState } from './lens_slice';
import { timeRangeMiddleware } from './time_range_middleware';
import { optimizingMiddleware } from './optimizing_middleware';
import { externalContextMiddleware } from './external_context_middleware';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
@ -17,19 +18,29 @@ import { LensAppState, LensState } from './types';
export * from './types';
export const reducer = {
app: appSlice.reducer,
lens: lensSlice.reducer,
};
export const {
setState,
navigateAway,
onChangeFromEditorFrame,
setSaveable,
onActiveDataChange,
} = appSlice.actions;
updateState,
updateDatasourceState,
updateVisualizationState,
updateLayer,
switchVisualization,
selectSuggestion,
rollbackSuggestion,
submitSuggestion,
switchDatasource,
setToggleFullscreen,
} = lensSlice.actions;
export const getPreloadedState = (initializedState: Partial<LensAppState>) => {
const state = {
app: {
lens: {
...initialState,
...initializedState,
},
@ -45,15 +56,9 @@ export const makeConfigureStore = (
) => {
const middleware = [
...getDefaultMiddleware({
serializableCheck: {
ignoredActions: [
'app/setState',
'app/onChangeFromEditorFrame',
'app/onActiveDataChange',
'app/navigateAway',
],
},
serializableCheck: false,
}),
optimizingMiddleware(),
timeRangeMiddleware(data),
externalContextMiddleware(data),
];

View file

@ -0,0 +1,148 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Query } from 'src/plugins/data/public';
import {
switchDatasource,
switchVisualization,
setState,
updateState,
updateDatasourceState,
updateVisualizationState,
} from '.';
import { makeLensStore, defaultState } from '../mocks';
describe('lensSlice', () => {
const store = makeLensStore({});
const customQuery = { query: 'custom' } as Query;
// TODO: need to move some initialization logic from mounter
// describe('initialization', () => {
// })
describe('state update', () => {
it('setState: updates state ', () => {
const lensState = store.getState().lens;
expect(lensState).toEqual(defaultState);
store.dispatch(setState({ query: customQuery }));
const changedState = store.getState().lens;
expect(changedState).toEqual({ ...defaultState, query: customQuery });
});
it('updateState: updates state with updater', () => {
const customUpdater = jest.fn((state) => ({ ...state, query: customQuery }));
store.dispatch(updateState({ subType: 'UPDATE', updater: customUpdater }));
const changedState = store.getState().lens;
expect(changedState).toEqual({ ...defaultState, query: customQuery });
});
it('should update the corresponding visualization state on update', () => {
const newVisState = {};
store.dispatch(
updateVisualizationState({
visualizationId: 'testVis',
updater: newVisState,
})
);
expect(store.getState().lens.visualization.state).toBe(newVisState);
});
it('should update the datasource state with passed in reducer', () => {
const datasourceUpdater = jest.fn(() => ({ changed: true }));
store.dispatch(
updateDatasourceState({
datasourceId: 'testDatasource',
updater: datasourceUpdater,
})
);
expect(store.getState().lens.datasourceStates.testDatasource.state).toStrictEqual({
changed: true,
});
expect(datasourceUpdater).toHaveBeenCalledTimes(1);
});
it('should update the layer state with passed in reducer', () => {
const newDatasourceState = {};
store.dispatch(
updateDatasourceState({
datasourceId: 'testDatasource',
updater: newDatasourceState,
})
);
expect(store.getState().lens.datasourceStates.testDatasource.state).toStrictEqual(
newDatasourceState
);
});
it('should should switch active visualization', () => {
const newVisState = {};
store.dispatch(
switchVisualization({
newVisualizationId: 'testVis2',
initialState: newVisState,
})
);
expect(store.getState().lens.visualization.state).toBe(newVisState);
});
it('should should switch active visualization and update datasource state', () => {
const newVisState = {};
const newDatasourceState = {};
store.dispatch(
switchVisualization({
newVisualizationId: 'testVis2',
initialState: newVisState,
datasourceState: newDatasourceState,
datasourceId: 'testDatasource',
})
);
expect(store.getState().lens.visualization.state).toBe(newVisState);
expect(store.getState().lens.datasourceStates.testDatasource.state).toBe(newDatasourceState);
});
it('should switch active datasource and initialize new state', () => {
store.dispatch(
switchDatasource({
newDatasourceId: 'testDatasource2',
})
);
expect(store.getState().lens.activeDatasourceId).toEqual('testDatasource2');
expect(store.getState().lens.datasourceStates.testDatasource2.isLoading).toEqual(true);
});
it('not initialize already initialized datasource on switch', () => {
const datasource2State = {};
const customStore = makeLensStore({
preloadedState: {
datasourceStates: {
testDatasource: {
state: {},
isLoading: false,
},
testDatasource2: {
state: datasource2State,
isLoading: false,
},
},
},
});
customStore.dispatch(
switchDatasource({
newDatasourceId: 'testDatasource2',
})
);
expect(customStore.getState().lens.activeDatasourceId).toEqual('testDatasource2');
expect(customStore.getState().lens.datasourceStates.testDatasource2.isLoading).toEqual(false);
expect(customStore.getState().lens.datasourceStates.testDatasource2.state).toBe(
datasource2State
);
});
});
});

View file

@ -0,0 +1,262 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createSlice, current, PayloadAction } from '@reduxjs/toolkit';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { LensAppState } from './types';
export const initialState: LensAppState = {
searchSessionId: '',
filters: [],
query: { language: 'kuery', query: '' },
resolvedDateRange: { fromDate: '', toDate: '' },
isFullscreenDatasource: false,
isSaveable: false,
isLoading: false,
isLinkedToOriginatingApp: false,
activeDatasourceId: null,
datasourceStates: {},
visualization: {
state: null,
activeId: null,
},
};
export const lensSlice = createSlice({
name: 'lens',
initialState,
reducers: {
setState: (state, { payload }: PayloadAction<Partial<LensAppState>>) => {
return {
...state,
...payload,
};
},
onActiveDataChange: (state, { payload }: PayloadAction<TableInspectorAdapter>) => {
return {
...state,
activeData: payload,
};
},
setSaveable: (state, { payload }: PayloadAction<boolean>) => {
return {
...state,
isSaveable: payload,
};
},
updateState: (
state,
action: {
payload: {
subType: string;
updater: (prevState: LensAppState) => LensAppState;
};
}
) => {
return action.payload.updater(current(state) as LensAppState);
},
updateDatasourceState: (
state,
{
payload,
}: {
payload: {
updater: unknown | ((prevState: unknown) => unknown);
datasourceId: string;
clearStagedPreview?: boolean;
};
}
) => {
return {
...state,
datasourceStates: {
...state.datasourceStates,
[payload.datasourceId]: {
state:
typeof payload.updater === 'function'
? payload.updater(current(state).datasourceStates[payload.datasourceId].state)
: payload.updater,
isLoading: false,
},
},
stagedPreview: payload.clearStagedPreview ? undefined : state.stagedPreview,
};
},
updateVisualizationState: (
state,
{
payload,
}: {
payload: {
visualizationId: string;
updater: unknown | ((state: unknown) => unknown);
clearStagedPreview?: boolean;
};
}
) => {
if (!state.visualization.activeId) {
throw new Error('Invariant: visualization state got updated without active visualization');
}
// This is a safeguard that prevents us from accidentally updating the
// wrong visualization. This occurs in some cases due to the uncoordinated
// way we manage state across plugins.
if (state.visualization.activeId !== payload.visualizationId) {
return state;
}
return {
...state,
visualization: {
...state.visualization,
state:
typeof payload.updater === 'function'
? payload.updater(current(state.visualization.state))
: payload.updater,
},
stagedPreview: payload.clearStagedPreview ? undefined : state.stagedPreview,
};
},
updateLayer: (
state,
{
payload,
}: {
payload: {
layerId: string;
datasourceId: string;
updater: (state: unknown, layerId: string) => unknown;
};
}
) => {
return {
...state,
datasourceStates: {
...state.datasourceStates,
[payload.datasourceId]: {
...state.datasourceStates[payload.datasourceId],
state: payload.updater(
current(state).datasourceStates[payload.datasourceId].state,
payload.layerId
),
},
},
};
},
switchVisualization: (
state,
{
payload,
}: {
payload: {
newVisualizationId: string;
initialState: unknown;
datasourceState?: unknown;
datasourceId?: string;
};
}
) => {
return {
...state,
datasourceStates:
'datasourceId' in payload && payload.datasourceId
? {
...state.datasourceStates,
[payload.datasourceId]: {
...state.datasourceStates[payload.datasourceId],
state: payload.datasourceState,
},
}
: state.datasourceStates,
visualization: {
...state.visualization,
activeId: payload.newVisualizationId,
state: payload.initialState,
},
stagedPreview: undefined,
};
},
selectSuggestion: (
state,
{
payload,
}: {
payload: {
newVisualizationId: string;
initialState: unknown;
datasourceState: unknown;
datasourceId: string;
};
}
) => {
return {
...state,
datasourceStates:
'datasourceId' in payload && payload.datasourceId
? {
...state.datasourceStates,
[payload.datasourceId]: {
...state.datasourceStates[payload.datasourceId],
state: payload.datasourceState,
},
}
: state.datasourceStates,
visualization: {
...state.visualization,
activeId: payload.newVisualizationId,
state: payload.initialState,
},
stagedPreview: state.stagedPreview || {
datasourceStates: state.datasourceStates,
visualization: state.visualization,
},
};
},
rollbackSuggestion: (state) => {
return {
...state,
...(state.stagedPreview || {}),
stagedPreview: undefined,
};
},
setToggleFullscreen: (state) => {
return { ...state, isFullscreenDatasource: !state.isFullscreenDatasource };
},
submitSuggestion: (state) => {
return {
...state,
stagedPreview: undefined,
};
},
switchDatasource: (
state,
{
payload,
}: {
payload: {
newDatasourceId: string;
};
}
) => {
return {
...state,
datasourceStates: {
...state.datasourceStates,
[payload.newDatasourceId]: state.datasourceStates[payload.newDatasourceId] || {
state: null,
isLoading: true,
},
},
activeDatasourceId: payload.newDatasourceId,
};
},
navigateAway: (state) => state,
},
});
export const reducer = {
lens: lensSlice.reducer,
};

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { LensAppState } from './types';
/** cancels updates to the store that don't change the state */
export const optimizingMiddleware = () => (store: MiddlewareAPI) => {
return (next: Dispatch) => (action: PayloadAction<Partial<LensAppState>>) => {
if (action.type === 'lens/onActiveDataChange') {
if (isEqual(store.getState().lens.activeData, action.payload)) {
return;
}
}
next(action);
};
};

View file

@ -17,10 +17,9 @@ import { timeRangeMiddleware } from './time_range_middleware';
import { Observable, Subject } from 'rxjs';
import { DataPublicPluginStart, esFilters } from '../../../../../src/plugins/data/public';
import moment from 'moment';
import { initialState } from './app_slice';
import { initialState } from './lens_slice';
import { LensAppState } from './types';
import { PayloadAction } from '@reduxjs/toolkit';
import { Document } from '../persistence';
const sessionIdSubject = new Subject<string>();
@ -132,7 +131,7 @@ function makeDefaultData(): jest.Mocked<DataPublicPluginStart> {
const createMiddleware = (data: DataPublicPluginStart) => {
const middleware = timeRangeMiddleware(data);
const store = {
getState: jest.fn(() => ({ app: initialState })),
getState: jest.fn(() => ({ lens: initialState })),
dispatch: jest.fn(),
};
const next = jest.fn();
@ -157,8 +156,13 @@ describe('timeRangeMiddleware', () => {
});
const { next, invoke, store } = createMiddleware(data);
const action = {
type: 'app/setState',
payload: { lastKnownDoc: ('new' as unknown) as Document },
type: 'lens/setState',
payload: {
visualization: {
state: {},
activeId: 'id2',
},
},
};
invoke(action);
expect(store.dispatch).toHaveBeenCalledWith({
@ -169,7 +173,7 @@ describe('timeRangeMiddleware', () => {
},
searchSessionId: 'sessionId-1',
},
type: 'app/setState',
type: 'lens/setState',
});
expect(next).toHaveBeenCalledWith(action);
});
@ -187,8 +191,39 @@ describe('timeRangeMiddleware', () => {
});
const { next, invoke, store } = createMiddleware(data);
const action = {
type: 'app/setState',
payload: { lastKnownDoc: ('new' as unknown) as Document },
type: 'lens/setState',
payload: {
visualization: {
state: {},
activeId: 'id2',
},
},
};
invoke(action);
expect(store.dispatch).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(action);
});
it('does not trigger another update when the update already contains searchSessionId', () => {
const data = makeDefaultData();
(data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 30000));
(data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({
from: 'now-2m',
to: 'now',
});
(data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({
min: moment(Date.now() - 100000),
max: moment(Date.now() - 30000),
});
const { next, invoke, store } = createMiddleware(data);
const action = {
type: 'lens/setState',
payload: {
visualization: {
state: {},
activeId: 'id2',
},
searchSessionId: 'searchSessionId',
},
};
invoke(action);
expect(store.dispatch).not.toHaveBeenCalled();

View file

@ -5,27 +5,26 @@
* 2.0.
*/
import { isEqual } from 'lodash';
import { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit';
import moment from 'moment';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { setState, LensDispatch } from '.';
import { LensAppState } from './types';
import { getResolvedDateRange, containsDynamicMath, TIME_LAG_PERCENTAGE_LIMIT } from '../utils';
/**
* checks if TIME_LAG_PERCENTAGE_LIMIT passed to renew searchSessionId
* and request new data.
*/
export const timeRangeMiddleware = (data: DataPublicPluginStart) => (store: MiddlewareAPI) => {
return (next: Dispatch) => (action: PayloadAction<Partial<LensAppState>>) => {
// if document was modified or sessionId check if too much time passed to update searchSessionId
if (
action.payload?.lastKnownDoc &&
!isEqual(action.payload?.lastKnownDoc, store.getState().app.lastKnownDoc)
) {
if (!action.payload?.searchSessionId) {
updateTimeRange(data, store.dispatch);
}
next(action);
};
};
function updateTimeRange(data: DataPublicPluginStart, dispatch: LensDispatch) {
const timefilter = data.query.timefilter.timefilter;
const unresolvedTimeRange = timefilter.getTime();

View file

@ -5,24 +5,33 @@
* 2.0.
*/
import { Filter, IndexPattern, Query, SavedQuery } from '../../../../../src/plugins/data/public';
import { Filter, Query, SavedQuery } from '../../../../../src/plugins/data/public';
import { Document } from '../persistence';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { DateRange } from '../../common';
export interface LensAppState {
export interface PreviewState {
visualization: {
activeId: string | null;
state: unknown;
};
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>;
}
export interface EditorFrameState extends PreviewState {
activeDatasourceId: string | null;
stagedPreview?: PreviewState;
isFullscreenDatasource?: boolean;
}
export interface LensAppState extends EditorFrameState {
persistedDoc?: Document;
lastKnownDoc?: Document;
// index patterns used to determine which filters are available in the top nav.
indexPatternsForTopNav: IndexPattern[];
// Determines whether the lens editor shows the 'save and return' button, and the originating app breadcrumb.
isLinkedToOriginatingApp?: boolean;
isSaveable: boolean;
activeData?: TableInspectorAdapter;
isAppLoading: boolean;
isLoading: boolean;
query: Query;
filters: Filter[];
savedQuery?: SavedQuery;
@ -38,5 +47,5 @@ export type DispatchSetState = (
};
export interface LensState {
app: LensAppState;
lens: LensAppState;
}

View file

@ -7,7 +7,7 @@
import { IconType } from '@elastic/eui/src/components/icon/icon';
import { CoreSetup } from 'kibana/public';
import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public';
import { PaletteOutput } from 'src/plugins/charts/public';
import { SavedObjectReference } from 'kibana/public';
import { MutableRefObject } from 'react';
import { RowClickContext } from '../../../../src/plugins/ui_actions/public';
@ -45,13 +45,13 @@ export interface PublicAPIProps<T> {
}
export interface EditorFrameProps {
onError: ErrorCallback;
initialContext?: VisualizeFieldContext;
showNoDataPopover: () => void;
}
export interface EditorFrameInstance {
EditorFrameContainer: (props: EditorFrameProps) => React.ReactElement;
datasourceMap: Record<string, Datasource>;
visualizationMap: Record<string, Visualization>;
}
export interface EditorFrameSetup {
@ -525,20 +525,10 @@ export interface FramePublicAPI {
* If accessing, make sure to check whether expected columns actually exist.
*/
activeData?: Record<string, Datatable>;
dateRange: DateRange;
query: Query;
filters: Filter[];
searchSessionId: string;
/**
* A map of all available palettes (keys being the ids).
*/
availablePalettes: PaletteRegistry;
// Adds a new layer. This has a side effect of updating the datasource state
addNewLayer: () => string;
removeLayers: (layerIds: string[]) => void;
}
/**
@ -586,7 +576,7 @@ export interface Visualization<T = unknown> {
* - Loadingn from a saved visualization
* - When using suggestions, the suggested state is passed in
*/
initialize: (frame: FramePublicAPI, state?: T, mainPalette?: PaletteOutput) => T;
initialize: (addNewLayer: () => string, state?: T, mainPalette?: PaletteOutput) => T;
getMainPalette?: (state: T) => undefined | PaletteOutput;

View file

@ -9,6 +9,12 @@ import { i18n } from '@kbn/i18n';
import { IndexPattern, IndexPatternsContract, TimefilterContract } from 'src/plugins/data/public';
import { IUiSettingsClient } from 'kibana/public';
import moment from 'moment-timezone';
import { SavedObjectReference } from 'kibana/public';
import { Filter, Query } from 'src/plugins/data/public';
import { uniq } from 'lodash';
import { Document } from './persistence/saved_object_store';
import { Datasource } from './types';
import { extractFilterReferences } from './persistence';
export function getVisualizeGeoFieldMessage(fieldType: string) {
return i18n.translate('xpack.lens.visualizeGeoFieldMessage', {
@ -32,7 +38,105 @@ export function containsDynamicMath(dateMathString: string) {
export const TIME_LAG_PERCENTAGE_LIMIT = 0.02;
export async function getAllIndexPatterns(
export function getTimeZone(uiSettings: IUiSettingsClient) {
const configuredTimeZone = uiSettings.get('dateFormat:tz');
if (configuredTimeZone === 'Browser') {
return moment.tz.guess();
}
return configuredTimeZone;
}
export function getActiveDatasourceIdFromDoc(doc?: Document) {
if (!doc) {
return null;
}
const [firstDatasourceFromDoc] = Object.keys(doc.state.datasourceStates);
return firstDatasourceFromDoc || null;
}
export const getInitialDatasourceId = (
datasourceMap: Record<string, Datasource>,
doc?: Document
) => {
return (doc && getActiveDatasourceIdFromDoc(doc)) || Object.keys(datasourceMap)[0] || null;
};
export interface GetIndexPatternsObjects {
activeDatasources: Record<string, Datasource>;
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>;
visualization: {
activeId: string | null;
state: unknown;
};
filters: Filter[];
query: Query;
title: string;
description?: string;
persistedId?: string;
}
export function getSavedObjectFormat({
activeDatasources,
datasourceStates,
visualization,
filters,
query,
title,
description,
persistedId,
}: GetIndexPatternsObjects): Document {
const persistibleDatasourceStates: Record<string, unknown> = {};
const references: SavedObjectReference[] = [];
Object.entries(activeDatasources).forEach(([id, datasource]) => {
const { state: persistableState, savedObjectReferences } = datasource.getPersistableState(
datasourceStates[id].state
);
persistibleDatasourceStates[id] = persistableState;
references.push(...savedObjectReferences);
});
const { persistableFilters, references: filterReferences } = extractFilterReferences(filters);
references.push(...filterReferences);
return {
savedObjectId: persistedId,
title,
description,
type: 'lens',
visualizationType: visualization.activeId,
state: {
datasourceStates: persistibleDatasourceStates,
visualization: visualization.state,
query,
filters: persistableFilters,
},
references,
};
}
export function getIndexPatternsIds({
activeDatasources,
datasourceStates,
}: {
activeDatasources: Record<string, Datasource>;
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>;
}): string[] {
const references: SavedObjectReference[] = [];
Object.entries(activeDatasources).forEach(([id, datasource]) => {
const { savedObjectReferences } = datasource.getPersistableState(datasourceStates[id].state);
references.push(...savedObjectReferences);
});
const uniqueFilterableIndexPatternIds = uniq(
references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)
);
return uniqueFilterableIndexPatternIds;
}
export async function getIndexPatternsObjects(
ids: string[],
indexPatternsService: IndexPatternsContract
): Promise<{ indexPatterns: IndexPattern[]; rejectedIds: string[] }> {
@ -46,12 +150,3 @@ export async function getAllIndexPatterns(
// return also the rejected ids in case we want to show something later on
return { indexPatterns: fullfilled.map((response) => response.value), rejectedIds };
}
export function getTimeZone(uiSettings: IUiSettingsClient) {
const configuredTimeZone = uiSettings.get('dateFormat:tz');
if (configuredTimeZone === 'Browser') {
return moment.tz.guess();
}
return configuredTimeZone;
}

View file

@ -10,7 +10,7 @@ import { Position } from '@elastic/charts';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
import { getXyVisualization } from './xy_visualization';
import { Operation } from '../types';
import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks';
import { createMockDatasource, createMockFramePublicAPI } from '../mocks';
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
describe('#toExpression', () => {

View file

@ -9,7 +9,7 @@ import React from 'react';
import { shallowWithIntl as shallow } from '@kbn/test/jest';
import { Position } from '@elastic/charts';
import { FramePublicAPI } from '../../types';
import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks';
import { createMockDatasource, createMockFramePublicAPI } from '../../mocks';
import { State } from '../types';
import { VisualOptionsPopover } from './visual_options_popover';
import { ToolbarPopover } from '../../shared_components';

View file

@ -9,7 +9,7 @@ import { getXyVisualization } from './visualization';
import { Position } from '@elastic/charts';
import { Operation } from '../types';
import { State, SeriesType, XYLayerConfig } from './types';
import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks';
import { createMockDatasource, createMockFramePublicAPI } from '../mocks';
import { LensIconChartBar } from '../assets/chart_bar';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
@ -132,8 +132,7 @@ describe('xy_visualization', () => {
describe('#initialize', () => {
it('loads default state', () => {
const mockFrame = createMockFramePublicAPI();
const initialState = xyVisualization.initialize(mockFrame);
const initialState = xyVisualization.initialize(() => 'l1');
expect(initialState.layers).toHaveLength(1);
expect(initialState.layers[0].xAccessor).not.toBeDefined();
@ -144,7 +143,7 @@ describe('xy_visualization', () => {
"layers": Array [
Object {
"accessors": Array [],
"layerId": "",
"layerId": "l1",
"position": "top",
"seriesType": "bar_stacked",
"showGridlines": false,
@ -162,9 +161,7 @@ describe('xy_visualization', () => {
});
it('loads from persisted state', () => {
expect(xyVisualization.initialize(createMockFramePublicAPI(), exampleState())).toEqual(
exampleState()
);
expect(xyVisualization.initialize(() => 'first', exampleState())).toEqual(exampleState());
});
});

View file

@ -152,7 +152,7 @@ export const getXyVisualization = ({
getSuggestions,
initialize(frame, state) {
initialize(addNewLayer, state) {
return (
state || {
title: 'Empty XY chart',
@ -161,7 +161,7 @@ export const getXyVisualization = ({
preferredSeriesType: defaultSeriesType,
layers: [
{
layerId: frame.addNewLayer(),
layerId: addNewLayer(),
accessors: [],
position: Position.Top,
seriesType: defaultSeriesType,

View file

@ -13,7 +13,7 @@ import { AxisSettingsPopover } from './axis_settings_popover';
import { FramePublicAPI } from '../types';
import { State } from './types';
import { Position } from '@elastic/charts';
import { createMockFramePublicAPI, createMockDatasource } from '../editor_frame_service/mocks';
import { createMockFramePublicAPI, createMockDatasource } from '../mocks';
import { chartPluginMock } from 'src/plugins/charts/public/mocks';
import { EuiColorPicker } from '@elastic/eui';

View file

@ -99,7 +99,6 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) {
{isSaveOpen && lensAttributes && (
<LensSaveModalComponent
isVisible={isSaveOpen}
initialInput={(lensAttributes as unknown) as LensEmbeddableInput}
onClose={() => setIsSaveOpen(false)}
onSave={() => {}}

View file

@ -142,8 +142,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.lens.configureDimension(
{
dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension',
operation: 'terms',
field: 'ip',
operation: 'date_histogram',
field: '@timestamp',
},
1
);

View file

@ -223,10 +223,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// remove the x dimension to trigger the validation error
await PageObjects.lens.removeDimension('lnsXY_xDimensionPanel');
await PageObjects.lens.saveAndReturn();
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('embeddable-lens-failure');
await PageObjects.lens.expectSaveAndReturnButtonDisabled();
});
});
}

View file

@ -491,6 +491,12 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
await testSubjects.click('lnsApp_saveAndReturnButton');
},
async expectSaveAndReturnButtonDisabled() {
const button = await testSubjects.find('lnsApp_saveAndReturnButton', 10000);
const disabledAttr = await button.getAttribute('disabled');
expect(disabledAttr).to.be('true');
},
async editDimensionLabel(label: string) {
await testSubjects.setValue('indexPattern-label-edit', label, { clearWithKeyboard: true });
},