diff --git a/package.json b/package.json
index 1369b1d105aa4..627e8abd9d259 100644
--- a/package.json
+++ b/package.json
@@ -161,6 +161,7 @@
"@mapbox/mapbox-gl-draw": "1.3.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mapbox/vector-tile": "1.3.1",
+ "@reduxjs/toolkit": "^1.5.1",
"@scant/router": "^0.1.1",
"@slack/webhook": "^5.0.4",
"@turf/along": "6.0.1",
@@ -173,6 +174,7 @@
"@turf/distance": "6.0.1",
"@turf/helpers": "6.0.1",
"@turf/length": "^6.0.2",
+ "@types/redux-logger": "^3.0.8",
"JSONStream": "1.3.5",
"abort-controller": "^3.0.0",
"abortcontroller-polyfill": "^1.4.0",
@@ -365,6 +367,7 @@
"redux": "^4.0.5",
"redux-actions": "^2.6.5",
"redux-devtools-extension": "^2.13.8",
+ "redux-logger": "^3.0.6",
"redux-observable": "^1.2.0",
"redux-saga": "^1.1.3",
"redux-thunk": "^2.3.0",
diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx
index 72b8bfa38491a..30b4e2d954d2b 100644
--- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx
+++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx
@@ -6,15 +6,14 @@
*/
import React from 'react';
-import { Observable, Subject } from 'rxjs';
+import { Subject } from 'rxjs';
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { App } from './app';
import { LensAppProps, LensAppServices } from './types';
import { EditorFrameInstance, EditorFrameProps } from '../types';
import { Document } from '../persistence';
-import { DOC_TYPE } from '../../common';
-import { mount } from 'enzyme';
+import { makeDefaultServices, mountWithProvider } from '../mocks';
import { I18nProvider } from '@kbn/i18n/react';
import {
SavedObjectSaveModal,
@@ -22,31 +21,20 @@ import {
} from '../../../../../src/plugins/saved_objects/public';
import { createMemoryHistory } from 'history';
import {
- DataPublicPluginStart,
esFilters,
FilterManager,
IFieldType,
IIndexPattern,
- UI_SETTINGS,
+ IndexPattern,
+ Query,
} from '../../../../../src/plugins/data/public';
-import { navigationPluginMock } from '../../../../../src/plugins/navigation/public/mocks';
import { TopNavMenuData } from '../../../../../src/plugins/navigation/public';
-import { coreMock } from 'src/core/public/mocks';
-import {
- LensByValueInput,
- LensSavedObjectAttributes,
- LensByReferenceInput,
-} from '../editor_frame_service/embeddable/embeddable';
+import { LensByValueInput } from '../editor_frame_service/embeddable/embeddable';
import { SavedObjectReference } from '../../../../../src/core/types';
-import {
- mockAttributeService,
- createEmbeddableStateTransferMock,
-} from '../../../../../src/plugins/embeddable/public/mocks';
-import { LensAttributeService } from '../lens_attribute_service';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
-import { EmbeddableStateTransfer } from '../../../../../src/plugins/embeddable/public';
import moment from 'moment';
+import { setState, LensAppState } from '../state_management/index';
jest.mock('../editor_frame_service/editor_frame/expression_helpers');
jest.mock('src/core/public');
jest.mock('../../../../../src/plugins/saved_objects/public', () => {
@@ -61,13 +49,16 @@ jest.mock('../../../../../src/plugins/saved_objects/public', () => {
};
});
-const navigationStartMock = navigationPluginMock.createStartContract();
+jest.mock('lodash', () => {
+ const original = jest.requireActual('lodash');
-jest.spyOn(navigationStartMock.ui.TopNavMenu.prototype, 'constructor').mockImplementation(() => {
- return
;
+ return {
+ ...original,
+ debounce: (fn: unknown) => fn,
+ };
});
-const { TopNavMenu } = navigationStartMock.ui;
+// const navigationStartMock = navigationPluginMock.createStartContract();
function createMockFrame(): jest.Mocked {
return {
@@ -77,91 +68,7 @@ function createMockFrame(): jest.Mocked {
const sessionIdSubject = new Subject();
-function createMockSearchService() {
- let sessionIdCounter = 1;
- return {
- session: {
- start: jest.fn(() => `sessionId-${sessionIdCounter++}`),
- clear: jest.fn(),
- getSessionId: jest.fn(() => `sessionId-${sessionIdCounter}`),
- getSession$: jest.fn(() => sessionIdSubject.asObservable()),
- },
- };
-}
-
-function createMockFilterManager() {
- const unsubscribe = jest.fn();
-
- let subscriber: () => void;
- let filters: unknown = [];
-
- return {
- getUpdates$: () => ({
- subscribe: ({ next }: { next: () => void }) => {
- subscriber = next;
- return unsubscribe;
- },
- }),
- setFilters: jest.fn((newFilters: unknown[]) => {
- filters = newFilters;
- if (subscriber) subscriber();
- }),
- setAppFilters: jest.fn((newFilters: unknown[]) => {
- filters = newFilters;
- if (subscriber) subscriber();
- }),
- getFilters: () => filters,
- getGlobalFilters: () => {
- // @ts-ignore
- return filters.filter(esFilters.isFilterPinned);
- },
- removeAll: () => {
- filters = [];
- subscriber();
- },
- };
-}
-
-function createMockQueryString() {
- return {
- getQuery: jest.fn(() => ({ query: '', language: 'kuery' })),
- setQuery: jest.fn(),
- getDefaultQuery: jest.fn(() => ({ query: '', language: 'kuery' })),
- };
-}
-
-function createMockTimefilter() {
- const unsubscribe = jest.fn();
-
- let timeFilter = { from: 'now-7d', to: 'now' };
- let subscriber: () => void;
- return {
- getTime: jest.fn(() => timeFilter),
- setTime: jest.fn((newTimeFilter) => {
- timeFilter = newTimeFilter;
- if (subscriber) {
- subscriber();
- }
- }),
- getTimeUpdate$: () => ({
- subscribe: ({ next }: { next: () => void }) => {
- subscriber = next;
- return unsubscribe;
- },
- }),
- calculateBounds: jest.fn(() => ({
- min: moment('2021-01-10T04:00:00.000Z'),
- max: moment('2021-01-10T08:00:00.000Z'),
- })),
- getBounds: jest.fn(() => timeFilter),
- getRefreshInterval: () => {},
- getRefreshIntervalDefaults: () => {},
- getAutoRefreshFetch$: () => new Observable(),
- };
-}
-
describe('Lens App', () => {
- let core: ReturnType;
let defaultDoc: Document;
let defaultSavedObjectId: string;
@@ -171,27 +78,6 @@ describe('Lens App', () => {
expectedSaveAndReturnButton: { emphasize: true, testId: 'lnsApp_saveAndReturnButton' },
};
- function makeAttributeService(): LensAttributeService {
- const attributeServiceMock = mockAttributeService<
- LensSavedObjectAttributes,
- LensByValueInput,
- LensByReferenceInput
- >(
- DOC_TYPE,
- {
- saveMethod: jest.fn(),
- unwrapMethod: jest.fn(),
- checkForDuplicateTitle: jest.fn(),
- },
- core
- );
- attributeServiceMock.unwrapAttributes = jest.fn().mockResolvedValue(defaultDoc);
- attributeServiceMock.wrapAttributes = jest
- .fn()
- .mockResolvedValue({ savedObjectId: defaultSavedObjectId });
- return attributeServiceMock;
- }
-
function makeDefaultProps(): jest.Mocked {
return {
editorFrame: createMockFrame(),
@@ -203,64 +89,15 @@ describe('Lens App', () => {
};
}
- function makeDefaultServices(): jest.Mocked {
- return {
- http: core.http,
- chrome: core.chrome,
- overlays: core.overlays,
- uiSettings: core.uiSettings,
- navigation: navigationStartMock,
- notifications: core.notifications,
- attributeService: makeAttributeService(),
- savedObjectsClient: core.savedObjects.client,
- dashboardFeatureFlag: { allowByValueEmbeddables: false },
- stateTransfer: createEmbeddableStateTransferMock() as EmbeddableStateTransfer,
- getOriginatingAppName: jest.fn(() => 'defaultOriginatingApp'),
- application: {
- ...core.application,
- capabilities: {
- ...core.application.capabilities,
- visualize: { save: true, saveQuery: true, show: true },
- },
- getUrlForApp: jest.fn((appId: string) => `/testbasepath/app/${appId}#/`),
- },
- data: ({
- query: {
- filterManager: createMockFilterManager(),
- timefilter: {
- timefilter: createMockTimefilter(),
- },
- queryString: createMockQueryString(),
- state$: new Observable(),
- },
- indexPatterns: {
- get: jest.fn((id) => {
- return new Promise((resolve) => resolve({ id }));
- }),
- },
- search: createMockSearchService(),
- nowProvider: {
- get: jest.fn(),
- },
- } as unknown) as DataPublicPluginStart,
- storage: {
- get: jest.fn(),
- set: jest.fn(),
- remove: jest.fn(),
- clear: jest.fn(),
- },
- };
- }
-
- function mountWith({
- props: incomingProps,
- services: incomingServices,
+ async function mountWith({
+ props = makeDefaultProps(),
+ services = makeDefaultServices(sessionIdSubject),
+ storePreloadedState,
}: {
props?: jest.Mocked;
services?: jest.Mocked;
+ storePreloadedState?: Partial;
}) {
- const props = incomingProps ?? makeDefaultProps();
- const services = incomingServices ?? makeDefaultServices();
const wrappingComponent: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
@@ -270,61 +107,40 @@ describe('Lens App', () => {
);
};
+
+ const { instance, lensStore } = await mountWithProvider(
+ ,
+ services.data,
+ storePreloadedState,
+ wrappingComponent
+ );
+
const frame = props.editorFrame as ReturnType;
- const component = mount(, { wrappingComponent });
- return { component, frame, props, services };
+ return { instance, frame, props, services, lensStore };
}
beforeEach(() => {
- core = coreMock.createStart({ basePath: '/testbasepath' });
defaultSavedObjectId = '1234';
defaultDoc = ({
savedObjectId: defaultSavedObjectId,
title: 'An extremely cool default document!',
expression: 'definitely a valid expression',
state: {
- query: 'kuery',
+ query: 'lucene',
filters: [{ query: { match_phrase: { src: 'test' } } }],
},
references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }],
} as unknown) as Document;
-
- core.uiSettings.get.mockImplementation(
- jest.fn((type) => {
- if (type === UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS) {
- return { from: 'now-7d', to: 'now' };
- } else if (type === UI_SETTINGS.SEARCH_QUERY_LANGUAGE) {
- return 'kuery';
- } else if (type === 'state:storeInSessionStorage') {
- return false;
- } else {
- return [];
- }
- })
- );
});
- it('renders the editor frame', () => {
- const { frame } = mountWith({});
+ it('renders the editor frame', async () => {
+ const { frame } = await mountWith({});
expect(frame.EditorFrameContainer.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
- "dateRange": Object {
- "fromDate": "2021-01-10T04:00:00.000Z",
- "toDate": "2021-01-10T08:00:00.000Z",
- },
- "doc": undefined,
- "filters": Array [],
"initialContext": undefined,
- "onChange": [Function],
"onError": [Function],
- "query": Object {
- "language": "kuery",
- "query": "",
- },
- "savedQuery": undefined,
- "searchSessionId": "sessionId-1",
"showNoDataPopover": [Function],
},
Object {},
@@ -333,13 +149,8 @@ describe('Lens App', () => {
`);
});
- it('clears app filters on load', () => {
- const { services } = mountWith({});
- expect(services.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([]);
- });
-
- it('passes global filters to frame', async () => {
- const services = makeDefaultServices();
+ it('updates global filters with store state', async () => {
+ const services = makeDefaultServices(sessionIdSubject);
const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType;
const pinnedFilter = esFilters.buildExistsFilter(pinnedField, indexPattern);
@@ -349,25 +160,28 @@ describe('Lens App', () => {
services.data.query.filterManager.getGlobalFilters = jest.fn().mockImplementation(() => {
return [pinnedFilter];
});
- const { component, frame } = mountWith({ services });
+ const { instance, lensStore } = await mountWith({ services });
- component.update();
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
- dateRange: { fromDate: '2021-01-10T04:00:00.000Z', toDate: '2021-01-10T08:00:00.000Z' },
- query: { query: '', language: 'kuery' },
+ instance.update();
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
+ query: { query: '', language: 'lucene' },
filters: [pinnedFilter],
+ resolvedDateRange: {
+ fromDate: '2021-01-10T04:00:00.000Z',
+ toDate: '2021-01-10T08:00:00.000Z',
+ },
}),
- {}
- );
+ });
+
expect(services.data.query.filterManager.getFilters).not.toHaveBeenCalled();
});
- it('displays errors from the frame in a toast', () => {
- const { component, frame, services } = mountWith({});
+ 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' });
- component.update();
+ instance.update();
expect(services.notifications.toasts.addDanger).toHaveBeenCalled();
});
@@ -384,7 +198,7 @@ describe('Lens App', () => {
} as unknown) as Document;
it('sets breadcrumbs when the document title changes', async () => {
- const { component, services } = mountWith({});
+ const { instance, services, lensStore } = await mountWith({});
expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([
{
@@ -395,9 +209,13 @@ describe('Lens App', () => {
{ text: 'Create' },
]);
- services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(breadcrumbDoc);
await act(async () => {
- component.setProps({ initialInput: { savedObjectId: breadcrumbDocSavedObjectId } });
+ instance.setProps({ initialInput: { savedObjectId: breadcrumbDocSavedObjectId } });
+ lensStore.dispatch(
+ setState({
+ persistedDoc: breadcrumbDoc,
+ })
+ );
});
expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([
@@ -412,10 +230,17 @@ describe('Lens App', () => {
it('sets originatingApp breadcrumb when the document title changes', async () => {
const props = makeDefaultProps();
- const services = makeDefaultServices();
+ const services = makeDefaultServices(sessionIdSubject);
props.incomingState = { originatingApp: 'coolContainer' };
services.getOriginatingAppName = jest.fn(() => 'The Coolest Container Ever Made');
- const { component } = mountWith({ props, services });
+
+ const { instance, lensStore } = await mountWith({
+ props,
+ services,
+ storePreloadedState: {
+ isLinkedToOriginatingApp: true,
+ },
+ });
expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([
{ text: 'The Coolest Container Ever Made', onClick: expect.anything() },
@@ -427,9 +252,14 @@ describe('Lens App', () => {
{ text: 'Create' },
]);
- services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue(breadcrumbDoc);
await act(async () => {
- component.setProps({ initialInput: { savedObjectId: breadcrumbDocSavedObjectId } });
+ instance.setProps({ initialInput: { savedObjectId: breadcrumbDocSavedObjectId } });
+
+ lensStore.dispatch(
+ setState({
+ persistedDoc: breadcrumbDoc,
+ })
+ );
});
expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([
@@ -445,99 +275,36 @@ describe('Lens App', () => {
});
describe('persistence', () => {
- it('does not load a document if there is no initial input', () => {
- const { services } = mountWith({});
- expect(services.attributeService.unwrapAttributes).not.toHaveBeenCalled();
- });
-
it('loads a document and uses query and filters if initial input is provided', async () => {
- const { component, frame, services } = mountWith({});
- services.attributeService.unwrapAttributes = jest.fn().mockResolvedValue({
+ const { instance, lensStore, services } = await mountWith({});
+ const document = ({
savedObjectId: defaultSavedObjectId,
state: {
query: 'fake query',
filters: [{ query: { match_phrase: { src: 'test' } } }],
},
references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }],
- });
+ } as unknown) as Document;
- await act(async () => {
- component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
+ act(() => {
+ lensStore.dispatch(
+ setState({
+ query: ('fake query' as unknown) as Query,
+ indexPatternsForTopNav: ([{ id: '1' }] as unknown) as IndexPattern[],
+ lastKnownDoc: document,
+ persistedDoc: document,
+ })
+ );
});
+ instance.update();
- expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
- savedObjectId: defaultSavedObjectId,
- });
- expect(services.data.indexPatterns.get).toHaveBeenCalledWith('1');
- expect(services.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([
- { query: { match_phrase: { src: 'test' } } },
- ]);
- expect(TopNavMenu).toHaveBeenCalledWith(
+ expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
query: 'fake query',
indexPatterns: [{ id: '1' }],
}),
{}
);
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
- doc: expect.objectContaining({
- savedObjectId: defaultSavedObjectId,
- state: expect.objectContaining({
- query: 'fake query',
- filters: [{ query: { match_phrase: { src: 'test' } } }],
- }),
- }),
- }),
- {}
- );
- });
-
- it('does not load documents on sequential renders unless the id changes', async () => {
- const { services, component } = mountWith({});
-
- await act(async () => {
- component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
- });
- await act(async () => {
- component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
- });
- expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- component.setProps({ initialInput: { savedObjectId: '5678' } });
- });
-
- expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(2);
- });
-
- it('handles document load errors', async () => {
- const services = makeDefaultServices();
- services.attributeService.unwrapAttributes = jest.fn().mockRejectedValue('failed to load');
- const { component, props } = mountWith({ services });
-
- await act(async () => {
- component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
- });
-
- expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
- savedObjectId: defaultSavedObjectId,
- });
- expect(services.notifications.toasts.addDanger).toHaveBeenCalled();
- expect(props.redirectTo).toHaveBeenCalled();
- });
-
- it('adds to the recently accessed list on load', async () => {
- const { component, services } = mountWith({});
-
- await act(async () => {
- component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
- });
- expect(services.chrome.recentlyAccessed.add).toHaveBeenCalledWith(
- '/app/lens#/edit/1234',
- 'An extremely cool default document!',
- '1234'
- );
});
describe('save buttons', () => {
@@ -584,7 +351,7 @@ describe('Lens App', () => {
: undefined,
};
- const services = makeDefaultServices();
+ const services = makeDefaultServices(sessionIdSubject);
services.attributeService.wrapAttributes = jest
.fn()
.mockImplementation(async ({ savedObjectId }) => ({
@@ -599,39 +366,27 @@ describe('Lens App', () => {
},
} as jest.ResolvedValue);
- let frame: jest.Mocked = {} as jest.Mocked;
- let component: ReactWrapper = {} as ReactWrapper;
- await act(async () => {
- const { frame: newFrame, component: newComponent } = mountWith({ services, props });
- frame = newFrame;
- component = newComponent;
- });
-
- if (initialSavedObjectId) {
- expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(1);
- } else {
- expect(services.attributeService.unwrapAttributes).not.toHaveBeenCalled();
- }
+ const { frame, instance, lensStore } = await mountWith({ services, props });
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
+ act(() => {
+ lensStore.dispatch(
+ setState({
+ isSaveable: true,
+ lastKnownDoc: { savedObjectId: initialSavedObjectId, ...lastKnownDoc } as Document,
+ })
+ );
+ });
- act(() =>
- onChange({
- filterableIndexPatterns: [],
- doc: { savedObjectId: initialSavedObjectId, ...lastKnownDoc } as Document,
- isSaveable: true,
- })
- );
- component.update();
- expect(getButton(component).disableButton).toEqual(false);
+ instance.update();
+ expect(getButton(instance).disableButton).toEqual(false);
await act(async () => {
- testSave(component, { ...saveProps });
+ testSave(instance, { ...saveProps });
});
- return { props, services, component, frame };
+ return { props, services, instance, frame, lensStore };
}
it('shows a disabled save button when the user does not have permissions', async () => {
- const services = makeDefaultServices();
+ const services = makeDefaultServices(sessionIdSubject);
services.application = {
...services.application,
capabilities: {
@@ -639,36 +394,36 @@ describe('Lens App', () => {
visualize: { save: false, saveQuery: false, show: true },
},
};
- const { component, frame } = mountWith({ services });
- expect(getButton(component).disableButton).toEqual(true);
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- act(() =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({ savedObjectId: 'will save this' } as unknown) as Document,
- isSaveable: true,
- })
- );
- component.update();
- expect(getButton(component).disableButton).toEqual(true);
+ const { instance, lensStore } = await mountWith({ services });
+ expect(getButton(instance).disableButton).toEqual(true);
+ act(() => {
+ lensStore.dispatch(
+ setState({
+ lastKnownDoc: ({ savedObjectId: 'will save this' } as unknown) as Document,
+ isSaveable: true,
+ })
+ );
+ });
+ instance.update();
+ expect(getButton(instance).disableButton).toEqual(true);
});
it('shows a save button that is enabled when the frame has provided its state and does not show save and return or save as', async () => {
- const { component, frame } = mountWith({});
- expect(getButton(component).disableButton).toEqual(true);
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- act(() =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({ savedObjectId: 'will save this' } as unknown) as Document,
- isSaveable: true,
- })
- );
- component.update();
- expect(getButton(component).disableButton).toEqual(false);
+ const { instance, lensStore, services } = await mountWith({});
+ expect(getButton(instance).disableButton).toEqual(true);
+ act(() => {
+ lensStore.dispatch(
+ setState({
+ isSaveable: true,
+ lastKnownDoc: ({ savedObjectId: 'will save this' } as unknown) as Document,
+ })
+ );
+ });
+ instance.update();
+ expect(getButton(instance).disableButton).toEqual(false);
await act(async () => {
- const topNavMenuConfig = component.find(TopNavMenu).prop('config');
+ const topNavMenuConfig = instance.find(services.navigation.ui.TopNavMenu).prop('config');
expect(topNavMenuConfig).not.toContainEqual(
expect.objectContaining(navMenuItems.expectedSaveAndReturnButton)
);
@@ -683,7 +438,7 @@ describe('Lens App', () => {
it('Shows Save and Return and Save As buttons in create by value mode with originating app', async () => {
const props = makeDefaultProps();
- const services = makeDefaultServices();
+ const services = makeDefaultServices(sessionIdSubject);
services.dashboardFeatureFlag = { allowByValueEmbeddables: true };
props.incomingState = {
originatingApp: 'ultraDashboard',
@@ -697,10 +452,16 @@ describe('Lens App', () => {
} as LensByValueInput,
};
- const { component } = mountWith({ props, services });
+ const { instance } = await mountWith({
+ props,
+ services,
+ storePreloadedState: {
+ isLinkedToOriginatingApp: true,
+ },
+ });
await act(async () => {
- const topNavMenuConfig = component.find(TopNavMenu).prop('config');
+ const topNavMenuConfig = instance.find(services.navigation.ui.TopNavMenu).prop('config');
expect(topNavMenuConfig).toContainEqual(
expect.objectContaining(navMenuItems.expectedSaveAndReturnButton)
);
@@ -720,10 +481,15 @@ describe('Lens App', () => {
originatingApp: 'ultraDashboard',
};
- const { component } = mountWith({ props });
+ const { instance, services } = await mountWith({
+ props,
+ storePreloadedState: {
+ isLinkedToOriginatingApp: true,
+ },
+ });
await act(async () => {
- const topNavMenuConfig = component.find(TopNavMenu).prop('config');
+ const topNavMenuConfig = instance.find(services.navigation.ui.TopNavMenu).prop('config');
expect(topNavMenuConfig).toContainEqual(
expect.objectContaining(navMenuItems.expectedSaveAndReturnButton)
);
@@ -770,7 +536,7 @@ describe('Lens App', () => {
});
it('saves the latest doc as a copy', async () => {
- const { props, services, component } = await save({
+ const { props, services, instance } = await save({
initialSavedObjectId: defaultSavedObjectId,
newCopyOnSave: true,
newTitle: 'hello there',
@@ -784,7 +550,7 @@ describe('Lens App', () => {
);
expect(props.redirectTo).toHaveBeenCalledWith(defaultSavedObjectId);
await act(async () => {
- component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
+ instance.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
});
expect(services.attributeService.wrapAttributes).toHaveBeenCalledTimes(1);
expect(services.notifications.toasts.addSuccess).toHaveBeenCalledWith(
@@ -793,7 +559,7 @@ describe('Lens App', () => {
});
it('saves existing docs', async () => {
- const { props, services, component } = await save({
+ const { props, services, instance, lensStore } = await save({
initialSavedObjectId: defaultSavedObjectId,
newCopyOnSave: false,
newTitle: 'hello there',
@@ -808,35 +574,51 @@ describe('Lens App', () => {
);
expect(props.redirectTo).not.toHaveBeenCalled();
await act(async () => {
- component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
+ instance.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
});
- expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(1);
+
+ 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'"
);
});
it('handles save failure by showing a warning, but still allows another save', async () => {
- const services = makeDefaultServices();
+ const services = makeDefaultServices(sessionIdSubject);
services.attributeService.wrapAttributes = jest
.fn()
.mockRejectedValue({ message: 'failed' });
- const { component, props, frame } = mountWith({ services });
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- act(() =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({ id: undefined } as unknown) as Document,
- isSaveable: true,
- })
- );
- component.update();
+ const { instance, props, lensStore } = await mountWith({ services });
+ act(() => {
+ lensStore.dispatch(
+ setState({
+ isSaveable: true,
+ lastKnownDoc: ({ id: undefined } as unknown) as Document,
+ })
+ );
+ });
+
+ instance.update();
await act(async () => {
- testSave(component, { newCopyOnSave: false, newTitle: 'hello there' });
+ testSave(instance, { newCopyOnSave: false, newTitle: 'hello there' });
});
expect(props.redirectTo).not.toHaveBeenCalled();
- expect(getButton(component).disableButton).toEqual(false);
+ expect(getButton(instance).disableButton).toEqual(false);
});
it('saves new doc and redirects to originating app', async () => {
@@ -895,28 +677,29 @@ describe('Lens App', () => {
});
it('checks for duplicate title before saving', async () => {
- const services = makeDefaultServices();
+ const services = makeDefaultServices(sessionIdSubject);
services.attributeService.wrapAttributes = jest
.fn()
.mockReturnValue(Promise.resolve({ savedObjectId: '123' }));
- const { component, frame } = mountWith({ services });
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- await act(async () =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({ savedObjectId: '123' } as unknown) as Document,
- isSaveable: true,
- })
- );
- component.update();
+ const { instance, lensStore } = await mountWith({ services });
await act(async () => {
- component.setProps({ initialInput: { savedObjectId: '123' } });
- getButton(component).run(component.getDOMNode());
+ lensStore.dispatch(
+ setState({
+ isSaveable: true,
+ lastKnownDoc: ({ savedObjectId: '123' } as unknown) as Document,
+ })
+ );
});
- component.update();
+
+ instance.update();
+ await act(async () => {
+ instance.setProps({ initialInput: { savedObjectId: '123' } });
+ getButton(instance).run(instance.getDOMNode());
+ });
+ instance.update();
const onTitleDuplicate = jest.fn();
await act(async () => {
- component.find(SavedObjectSaveModal).prop('onSave')({
+ instance.find(SavedObjectSaveModal).prop('onSave')({
onTitleDuplicate,
isTitleDuplicateConfirmed: false,
newCopyOnSave: false,
@@ -933,19 +716,20 @@ describe('Lens App', () => {
});
it('does not show the copy button on first save', async () => {
- const { component, frame } = mountWith({});
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- await act(async () =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({} as unknown) as Document,
- isSaveable: true,
- })
- );
- component.update();
- await act(async () => getButton(component).run(component.getDOMNode()));
- component.update();
- expect(component.find(SavedObjectSaveModal).prop('showCopyOnSave')).toEqual(false);
+ const { instance, lensStore } = await mountWith({});
+ await act(async () => {
+ lensStore.dispatch(
+ setState({
+ isSaveable: true,
+ lastKnownDoc: ({} as unknown) as Document,
+ })
+ );
+ });
+
+ instance.update();
+ await act(async () => getButton(instance).run(instance.getDOMNode()));
+ instance.update();
+ expect(instance.find(SavedObjectSaveModal).prop('showCopyOnSave')).toEqual(false);
});
});
});
@@ -960,38 +744,38 @@ describe('Lens App', () => {
}
it('should be disabled when no data is available', async () => {
- const { component, frame } = mountWith({});
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- await act(async () =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({} as unknown) as Document,
- isSaveable: true,
- })
- );
- component.update();
- expect(getButton(component).disableButton).toEqual(true);
+ const { instance, lensStore } = await mountWith({});
+ await act(async () => {
+ lensStore.dispatch(
+ setState({
+ isSaveable: true,
+ lastKnownDoc: ({} as unknown) as Document,
+ })
+ );
+ });
+ instance.update();
+ expect(getButton(instance).disableButton).toEqual(true);
});
it('should disable download when not saveable', async () => {
- const { component, frame } = mountWith({});
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
-
- await act(async () =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({} as unknown) as Document,
- isSaveable: false,
- activeData: { layer1: { type: 'datatable', columns: [], rows: [] } },
- })
- );
+ const { instance, lensStore } = await mountWith({});
- component.update();
- expect(getButton(component).disableButton).toEqual(true);
+ await act(async () => {
+ lensStore.dispatch(
+ setState({
+ lastKnownDoc: ({} as unknown) as Document,
+ isSaveable: false,
+ activeData: { layer1: { type: 'datatable', columns: [], rows: [] } },
+ })
+ );
+ });
+
+ instance.update();
+ expect(getButton(instance).disableButton).toEqual(true);
});
it('should still be enabled even if the user is missing save permissions', async () => {
- const services = makeDefaultServices();
+ const services = makeDefaultServices(sessionIdSubject);
services.application = {
...services.application,
capabilities: {
@@ -1000,59 +784,63 @@ describe('Lens App', () => {
},
};
- const { component, frame } = mountWith({ services });
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- await act(async () =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({} as unknown) as Document,
- isSaveable: true,
- activeData: { layer1: { type: 'datatable', columns: [], rows: [] } },
- })
- );
- component.update();
- expect(getButton(component).disableButton).toEqual(false);
+ 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: [] } },
+ })
+ );
+ });
+ instance.update();
+ expect(getButton(instance).disableButton).toEqual(false);
});
});
describe('query bar state management', () => {
- it('uses the default time and query language settings', () => {
- const { frame } = mountWith({});
- expect(TopNavMenu).toHaveBeenCalledWith(
+ it('uses the default time and query language settings', async () => {
+ const { lensStore, services } = await mountWith({});
+ expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
- query: { query: '', language: 'kuery' },
+ query: { query: '', language: 'lucene' },
dateRangeFrom: 'now-7d',
dateRangeTo: 'now',
}),
{}
);
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
- dateRange: { fromDate: '2021-01-10T04:00:00.000Z', toDate: '2021-01-10T08:00:00.000Z' },
- query: { query: '', language: 'kuery' },
+
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
+ query: { query: '', language: 'lucene' },
+ resolvedDateRange: {
+ fromDate: '2021-01-10T04:00:00.000Z',
+ toDate: '2021-01-10T08:00:00.000Z',
+ },
}),
- {}
- );
+ });
});
it('updates the index patterns when the editor frame is changed', async () => {
- const { component, frame } = mountWith({});
- expect(TopNavMenu).toHaveBeenCalledWith(
+ const { instance, lensStore, services } = await mountWith({});
+ expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
indexPatterns: [],
}),
{}
);
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
await act(async () => {
- onChange({
- filterableIndexPatterns: ['1'],
- doc: ({ id: undefined } as unknown) as Document,
- isSaveable: true,
- });
+ lensStore.dispatch(
+ setState({
+ indexPatternsForTopNav: [{ id: '1' }] as IndexPattern[],
+ lastKnownDoc: ({} as unknown) as Document,
+ isSaveable: true,
+ })
+ );
});
- component.update();
- expect(TopNavMenu).toHaveBeenCalledWith(
+ instance.update();
+ expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
indexPatterns: [{ id: '1' }],
}),
@@ -1060,14 +848,16 @@ describe('Lens App', () => {
);
// Do it again to verify that the dirty checking is done right
await act(async () => {
- onChange({
- filterableIndexPatterns: ['2'],
- doc: ({ id: undefined } as unknown) as Document,
- isSaveable: true,
- });
+ lensStore.dispatch(
+ setState({
+ indexPatternsForTopNav: [{ id: '2' }] as IndexPattern[],
+ lastKnownDoc: ({} as unknown) as Document,
+ isSaveable: true,
+ })
+ );
});
- component.update();
- expect(TopNavMenu).toHaveBeenLastCalledWith(
+ instance.update();
+ expect(services.navigation.ui.TopNavMenu).toHaveBeenLastCalledWith(
expect.objectContaining({
indexPatterns: [{ id: '2' }],
}),
@@ -1075,20 +865,20 @@ describe('Lens App', () => {
);
});
- it('updates the editor frame when the user changes query or time in the search bar', () => {
- const { component, frame, services } = mountWith({});
+ 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({
min: moment('2021-01-09T04:00:00.000Z'),
max: moment('2021-01-09T08:00:00.000Z'),
});
act(() =>
- component.find(TopNavMenu).prop('onQuerySubmit')!({
+ instance.find(services.navigation.ui.TopNavMenu).prop('onQuerySubmit')!({
dateRange: { from: 'now-14d', to: 'now-7d' },
query: { query: 'new', language: 'lucene' },
})
);
- component.update();
- expect(TopNavMenu).toHaveBeenCalledWith(
+ instance.update();
+ expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
query: { query: 'new', language: 'lucene' },
dateRangeFrom: 'now-14d',
@@ -1100,64 +890,75 @@ describe('Lens App', () => {
from: 'now-14d',
to: 'now-7d',
});
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
- dateRange: { fromDate: '2021-01-09T04:00:00.000Z', toDate: '2021-01-09T08:00:00.000Z' },
+
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
query: { query: 'new', language: 'lucene' },
+ resolvedDateRange: {
+ fromDate: '2021-01-09T04:00:00.000Z',
+ toDate: '2021-01-09T08:00:00.000Z',
+ },
}),
- {}
- );
+ });
});
- it('updates the filters when the user changes them', () => {
- const { component, frame, services } = mountWith({});
+ it('updates the filters when the user changes them', async () => {
+ const { instance, services, lensStore } = await mountWith({});
const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
+ filters: [],
+ }),
+ });
act(() =>
services.data.query.filterManager.setFilters([
esFilters.buildExistsFilter(field, indexPattern),
])
);
- component.update();
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
+ instance.update();
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
filters: [esFilters.buildExistsFilter(field, indexPattern)],
}),
- {}
- );
+ });
});
- it('updates the searchSessionId when the user changes query or time in the search bar', () => {
- const { component, frame, services } = mountWith({});
+ it('updates the searchSessionId when the user changes query or time in the search bar', async () => {
+ const { instance, services, lensStore } = await mountWith({});
+
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
+ searchSessionId: `sessionId-1`,
+ }),
+ });
+
act(() =>
- component.find(TopNavMenu).prop('onQuerySubmit')!({
+ instance.find(services.navigation.ui.TopNavMenu).prop('onQuerySubmit')!({
dateRange: { from: 'now-14d', to: 'now-7d' },
query: { query: '', language: 'lucene' },
})
);
- component.update();
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
- searchSessionId: `sessionId-1`,
- }),
- {}
- );
+ instance.update();
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
+ searchSessionId: `sessionId-2`,
+ }),
+ });
// trigger again, this time changing just the query
act(() =>
- component.find(TopNavMenu).prop('onQuerySubmit')!({
+ instance.find(services.navigation.ui.TopNavMenu).prop('onQuerySubmit')!({
dateRange: { from: 'now-14d', to: 'now-7d' },
query: { query: 'new', language: 'lucene' },
})
);
- component.update();
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
- searchSessionId: `sessionId-2`,
+ instance.update();
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
+ searchSessionId: `sessionId-3`,
}),
- {}
- );
-
+ });
const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
act(() =>
@@ -1165,19 +966,18 @@ describe('Lens App', () => {
esFilters.buildExistsFilter(field, indexPattern),
])
);
- component.update();
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
- searchSessionId: `sessionId-3`,
+ instance.update();
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
+ searchSessionId: `sessionId-4`,
}),
- {}
- );
+ });
});
});
describe('saved query handling', () => {
- it('does not allow saving when the user is missing the saveQuery permission', () => {
- const services = makeDefaultServices();
+ it('does not allow saving when the user is missing the saveQuery permission', async () => {
+ const services = makeDefaultServices(sessionIdSubject);
services.application = {
...services.application,
capabilities: {
@@ -1185,16 +985,16 @@ describe('Lens App', () => {
visualize: { save: false, saveQuery: false, show: true },
},
};
- mountWith({ services });
- expect(TopNavMenu).toHaveBeenCalledWith(
+ await mountWith({ services });
+ expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({ showSaveQuery: false }),
{}
);
});
- it('persists the saved query ID when the query is saved', () => {
- const { component } = mountWith({});
- expect(TopNavMenu).toHaveBeenCalledWith(
+ it('persists the saved query ID when the query is saved', async () => {
+ const { instance, services } = await mountWith({});
+ expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
showSaveQuery: true,
savedQuery: undefined,
@@ -1205,7 +1005,7 @@ describe('Lens App', () => {
{}
);
act(() => {
- component.find(TopNavMenu).prop('onSaved')!({
+ instance.find(services.navigation.ui.TopNavMenu).prop('onSaved')!({
id: '1',
attributes: {
title: '',
@@ -1214,7 +1014,7 @@ describe('Lens App', () => {
},
});
});
- expect(TopNavMenu).toHaveBeenCalledWith(
+ expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
savedQuery: {
id: '1',
@@ -1229,10 +1029,10 @@ describe('Lens App', () => {
);
});
- it('changes the saved query ID when the query is updated', () => {
- const { component } = mountWith({});
+ it('changes the saved query ID when the query is updated', async () => {
+ const { instance, services } = await mountWith({});
act(() => {
- component.find(TopNavMenu).prop('onSaved')!({
+ instance.find(services.navigation.ui.TopNavMenu).prop('onSaved')!({
id: '1',
attributes: {
title: '',
@@ -1242,7 +1042,7 @@ describe('Lens App', () => {
});
});
act(() => {
- component.find(TopNavMenu).prop('onSavedQueryUpdated')!({
+ instance.find(services.navigation.ui.TopNavMenu).prop('onSavedQueryUpdated')!({
id: '2',
attributes: {
title: 'new title',
@@ -1251,7 +1051,7 @@ describe('Lens App', () => {
},
});
});
- expect(TopNavMenu).toHaveBeenCalledWith(
+ expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
savedQuery: {
id: '2',
@@ -1266,10 +1066,10 @@ describe('Lens App', () => {
);
});
- it('updates the query if saved query is selected', () => {
- const { component } = mountWith({});
+ it('updates the query if saved query is selected', async () => {
+ const { instance, services } = await mountWith({});
act(() => {
- component.find(TopNavMenu).prop('onSavedQueryUpdated')!({
+ instance.find(services.navigation.ui.TopNavMenu).prop('onSavedQueryUpdated')!({
id: '2',
attributes: {
title: 'new title',
@@ -1278,7 +1078,7 @@ describe('Lens App', () => {
},
});
});
- expect(TopNavMenu).toHaveBeenCalledWith(
+ expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
query: { query: 'abc:def', language: 'lucene' },
}),
@@ -1286,10 +1086,10 @@ describe('Lens App', () => {
);
});
- it('clears all existing unpinned filters when the active saved query is cleared', () => {
- const { component, frame, services } = mountWith({});
+ it('clears all existing unpinned filters when the active saved query is cleared', async () => {
+ const { instance, services, lensStore } = await mountWith({});
act(() =>
- component.find(TopNavMenu).prop('onQuerySubmit')!({
+ instance.find(services.navigation.ui.TopNavMenu).prop('onQuerySubmit')!({
dateRange: { from: 'now-14d', to: 'now-7d' },
query: { query: 'new', language: 'lucene' },
})
@@ -1301,23 +1101,22 @@ describe('Lens App', () => {
const pinned = esFilters.buildExistsFilter(pinnedField, indexPattern);
FilterManager.setFiltersStore([pinned], esFilters.FilterStateStore.GLOBAL_STATE);
act(() => services.data.query.filterManager.setFilters([pinned, unpinned]));
- component.update();
- act(() => component.find(TopNavMenu).prop('onClearSavedQuery')!());
- component.update();
- expect(frame.EditorFrameContainer).toHaveBeenLastCalledWith(
- expect.objectContaining({
+ instance.update();
+ act(() => instance.find(services.navigation.ui.TopNavMenu).prop('onClearSavedQuery')!());
+ instance.update();
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
filters: [pinned],
}),
- {}
- );
+ });
});
});
describe('search session id management', () => {
- it('updates the searchSessionId when the query is updated', () => {
- const { component, frame } = mountWith({});
+ it('updates the searchSessionId when the query is updated', async () => {
+ const { instance, lensStore, services } = await mountWith({});
act(() => {
- component.find(TopNavMenu).prop('onSaved')!({
+ instance.find(services.navigation.ui.TopNavMenu).prop('onSaved')!({
id: '1',
attributes: {
title: '',
@@ -1327,7 +1126,7 @@ describe('Lens App', () => {
});
});
act(() => {
- component.find(TopNavMenu).prop('onSavedQueryUpdated')!({
+ instance.find(services.navigation.ui.TopNavMenu).prop('onSavedQueryUpdated')!({
id: '2',
attributes: {
title: 'new title',
@@ -1336,37 +1135,18 @@ describe('Lens App', () => {
},
});
});
- component.update();
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
+ instance.update();
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
searchSessionId: `sessionId-2`,
}),
- {}
- );
- });
-
- it('re-renders the frame if session id changes from the outside', async () => {
- const services = makeDefaultServices();
- const { frame } = mountWith({ props: undefined, services });
-
- act(() => {
- sessionIdSubject.next('new-session-id');
- });
- await act(async () => {
- await new Promise((r) => setTimeout(r, 0));
});
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
- searchSessionId: `new-session-id`,
- }),
- {}
- );
});
- it('updates the searchSessionId when the active saved query is cleared', () => {
- const { component, frame, services } = mountWith({});
+ it('updates the searchSessionId when the active saved query is cleared', async () => {
+ const { instance, services, lensStore } = await mountWith({});
act(() =>
- component.find(TopNavMenu).prop('onQuerySubmit')!({
+ instance.find(services.navigation.ui.TopNavMenu).prop('onQuerySubmit')!({
dateRange: { from: 'now-14d', to: 'now-7d' },
query: { query: 'new', language: 'lucene' },
})
@@ -1378,15 +1158,14 @@ describe('Lens App', () => {
const pinned = esFilters.buildExistsFilter(pinnedField, indexPattern);
FilterManager.setFiltersStore([pinned], esFilters.FilterStateStore.GLOBAL_STATE);
act(() => services.data.query.filterManager.setFilters([pinned, unpinned]));
- component.update();
- act(() => component.find(TopNavMenu).prop('onClearSavedQuery')!());
- component.update();
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
- searchSessionId: `sessionId-2`,
+ instance.update();
+ act(() => instance.find(services.navigation.ui.TopNavMenu).prop('onClearSavedQuery')!());
+ instance.update();
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
+ searchSessionId: `sessionId-4`,
}),
- {}
- );
+ });
});
const mockUpdate = {
@@ -1407,70 +1186,39 @@ describe('Lens App', () => {
activeData: undefined,
};
- it('does not update the searchSessionId when the state changes', () => {
- const { component, frame } = mountWith({});
- act(() => {
- component.find(frame.EditorFrameContainer).prop('onChange')(mockUpdate);
- });
- component.update();
- expect(frame.EditorFrameContainer).not.toHaveBeenCalledWith(
- expect.objectContaining({
- searchSessionId: `sessionId-2`,
- }),
- {}
- );
- });
-
- it('does update the searchSessionId when the state changes and too much time passed', () => {
- const { component, frame, services } = mountWith({});
-
- // time range is 100,000ms ago to 30,000ms ago (that's a lag of 30 percent)
- (services.data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 30000));
- (services.data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({
- from: 'now-2m',
- to: 'now',
- });
- (services.data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({
- min: moment(Date.now() - 100000),
- max: moment(Date.now() - 30000),
- });
+ it('updates the state if session id changes from the outside', async () => {
+ const services = makeDefaultServices(sessionIdSubject);
+ const { lensStore } = await mountWith({ props: undefined, services });
act(() => {
- component.find(frame.EditorFrameContainer).prop('onChange')(mockUpdate);
+ sessionIdSubject.next('new-session-id');
});
- component.update();
- expect(frame.EditorFrameContainer).toHaveBeenCalledWith(
- expect.objectContaining({
- searchSessionId: `sessionId-2`,
- }),
- {}
- );
- });
-
- it('does not update the searchSessionId when the state changes and too little time has passed', () => {
- const { component, frame, services } = mountWith({});
-
- // time range is 100,000ms ago to 300ms ago (that's a lag of .3 percent, not enough to trigger a session update)
- (services.data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 300));
- (services.data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({
- from: 'now-2m',
- to: 'now',
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
});
- (services.data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({
- min: moment(Date.now() - 100000),
- max: moment(Date.now() - 300),
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
+ searchSessionId: `new-session-id`,
+ }),
});
+ });
+ it('does not update the searchSessionId when the state changes', async () => {
+ const { lensStore } = await mountWith({});
act(() => {
- component.find(frame.EditorFrameContainer).prop('onChange')(mockUpdate);
+ lensStore.dispatch(
+ setState({
+ indexPatternsForTopNav: [],
+ lastKnownDoc: mockUpdate.doc,
+ isSaveable: true,
+ })
+ );
});
- component.update();
- expect(frame.EditorFrameContainer).not.toHaveBeenCalledWith(
- expect.objectContaining({
- searchSessionId: `sessionId-2`,
+ expect(lensStore.getState()).toEqual({
+ app: expect.objectContaining({
+ searchSessionId: `sessionId-1`,
}),
- {}
- );
+ });
});
});
@@ -1483,16 +1231,16 @@ describe('Lens App', () => {
confirmLeave = jest.fn();
});
- it('should not show a confirm message if there is no expression to save', () => {
- const { props } = mountWith({});
+ it('should not show a confirm message if there is no expression to save', async () => {
+ const { props } = await mountWith({});
const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });
expect(defaultLeave).toHaveBeenCalled();
expect(confirmLeave).not.toHaveBeenCalled();
});
- it('does not confirm if the user is missing save permissions', () => {
- const services = makeDefaultServices();
+ it('does not confirm if the user is missing save permissions', async () => {
+ const services = makeDefaultServices(sessionIdSubject);
services.application = {
...services.application,
capabilities: {
@@ -1500,36 +1248,36 @@ describe('Lens App', () => {
visualize: { save: false, saveQuery: false, show: true },
},
};
- const { component, frame, props } = mountWith({ services });
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- act(() =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({
- savedObjectId: undefined,
- references: [],
- } as unknown) as Document,
- isSaveable: true,
- })
- );
- component.update();
+ 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 lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });
expect(defaultLeave).toHaveBeenCalled();
expect(confirmLeave).not.toHaveBeenCalled();
});
- it('should confirm when leaving with an unsaved doc', () => {
- const { component, frame, props } = mountWith({});
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- act(() =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({ savedObjectId: undefined, state: {} } as unknown) as Document,
- isSaveable: true,
- })
- );
- component.update();
+ 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 lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });
expect(confirmLeave).toHaveBeenCalled();
@@ -1537,22 +1285,19 @@ describe('Lens App', () => {
});
it('should confirm when leaving with unsaved changes to an existing doc', async () => {
- const { component, frame, props } = mountWith({});
- await act(async () => {
- component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
+ const { lensStore, props } = await mountWith({});
+ act(() => {
+ lensStore.dispatch(
+ setState({
+ persistedDoc: defaultDoc,
+ lastKnownDoc: ({
+ savedObjectId: defaultSavedObjectId,
+ references: [],
+ } as unknown) as Document,
+ isSaveable: true,
+ })
+ );
});
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- act(() =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({
- savedObjectId: defaultSavedObjectId,
- references: [],
- } as unknown) as Document,
- isSaveable: true,
- })
- );
- component.update();
const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });
expect(confirmLeave).toHaveBeenCalled();
@@ -1560,19 +1305,16 @@ describe('Lens App', () => {
});
it('should not confirm when changes are saved', async () => {
- const { component, frame, props } = mountWith({});
- await act(async () => {
- component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
+ const { lensStore, props } = await mountWith({});
+ act(() => {
+ lensStore.dispatch(
+ setState({
+ lastKnownDoc: defaultDoc,
+ persistedDoc: defaultDoc,
+ isSaveable: true,
+ })
+ );
});
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- act(() =>
- onChange({
- filterableIndexPatterns: [],
- doc: defaultDoc,
- isSaveable: true,
- })
- );
- component.update();
const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });
expect(defaultLeave).toHaveBeenCalled();
@@ -1580,19 +1322,19 @@ describe('Lens App', () => {
});
it('should confirm when the latest doc is invalid', async () => {
- const { component, frame, props } = mountWith({});
- await act(async () => {
- component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } });
+ const { lensStore, props } = await mountWith({});
+ act(() => {
+ lensStore.dispatch(
+ setState({
+ persistedDoc: defaultDoc,
+ lastKnownDoc: ({
+ savedObjectId: defaultSavedObjectId,
+ references: [],
+ } as unknown) as Document,
+ isSaveable: true,
+ })
+ );
});
- const onChange = frame.EditorFrameContainer.mock.calls[0][0].onChange;
- act(() =>
- onChange({
- filterableIndexPatterns: [],
- doc: ({ savedObjectId: defaultSavedObjectId, references: [] } as unknown) as Document,
- isSaveable: true,
- })
- );
- component.update();
const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });
expect(confirmLeave).toHaveBeenCalled();
diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx
index c172f36913c21..61ed2934a4001 100644
--- a/x-pack/plugins/lens/public/app_plugin/app.tsx
+++ b/x-pack/plugins/lens/public/app_plugin/app.tsx
@@ -7,49 +7,38 @@
import './app.scss';
-import _ from 'lodash';
-import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { isEqual, partition } 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 { Datatable } from 'src/plugins/expressions/public';
import { EuiBreadcrumb } from '@elastic/eui';
-import { delay, finalize, switchMap, tap } from 'rxjs/operators';
-import { downloadMultipleAs } from '../../../../../src/plugins/share/public';
import {
createKbnUrlStateStorage,
withNotifyOnErrors,
} from '../../../../../src/plugins/kibana_utils/public';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
-import {
- OnSaveProps,
- checkForDuplicateTitle,
-} from '../../../../../src/plugins/saved_objects/public';
+import { checkForDuplicateTitle } from '../../../../../src/plugins/saved_objects/public';
import { injectFilterReferences } from '../persistence';
import { trackUiEvent } from '../lens_ui_telemetry';
-import {
- DataPublicPluginStart,
- esFilters,
- exporters,
- Filter,
- IndexPattern as IndexPatternInstance,
- IndexPatternsContract,
- Query,
- SavedQuery,
- syncQueryStateWithUrl,
- waitUntilNextSessionCompletes$,
-} from '../../../../../src/plugins/data/public';
-import { LENS_EMBEDDABLE_TYPE, getFullPath, APP_ID } from '../../common';
-import { LensAppProps, LensAppServices, LensAppState } from './types';
-import { getLensTopNavConfig } from './lens_top_nav';
+import { esFilters, syncQueryStateWithUrl } from '../../../../../src/plugins/data/public';
+import { getFullPath, APP_ID } from '../../common';
+import { LensAppProps, LensAppServices, RunSave } from './types';
+import { LensTopNavMenu } from './lens_top_nav';
import { Document } from '../persistence';
import { SaveModal } from './save_modal';
import {
LensByReferenceInput,
LensEmbeddableInput,
} from '../editor_frame_service/embeddable/embeddable';
-import { useTimeRange } from './time_range';
import { EditorFrameInstance } from '../types';
+import {
+ setState as setAppState,
+ useLensSelector,
+ useLensDispatch,
+ LensAppState,
+ DispatchSetState,
+} from '../state_management';
export function App({
history,
@@ -67,7 +56,6 @@ export function App({
data,
chrome,
overlays,
- navigation,
uiSettings,
application,
stateTransfer,
@@ -81,29 +69,18 @@ export function App({
dashboardFeatureFlag,
} = useKibana().services;
- const startSession = useCallback(() => data.search.session.start(), [data.search.session]);
-
- const [state, setState] = useState(() => {
- return {
- 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)
- filters: !initialContext
- ? data.query.filterManager.getGlobalFilters()
- : data.query.filterManager.getFilters(),
- isLoading: Boolean(initialInput),
- indexPatternsForTopNav: [],
- isLinkedToOriginatingApp: Boolean(incomingState?.originatingApp),
- isSaveable: false,
- searchSessionId: startSession(),
- };
- });
+ const dispatch = useLensDispatch();
+ const dispatchSetState: DispatchSetState = useCallback(
+ (state: Partial) => dispatch(setAppState(state)),
+ [dispatch]
+ );
+
+ const appState = useLensSelector((state) => state.app);
// 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 } = state;
+ const { lastKnownDoc } = appState;
const showNoDataPopover = useCallback(() => {
setIndicateNoData(true);
@@ -116,19 +93,10 @@ export function App({
}, [
setIndicateNoData,
indicateNoData,
- state.query,
- state.filters,
- state.indexPatternsForTopNav,
- state.searchSessionId,
+ appState.indexPatternsForTopNav,
+ appState.searchSessionId,
]);
- const { resolvedDateRange, from: fromDate, to: toDate } = useTimeRange(
- data,
- state.lastKnownDoc,
- setState,
- state.searchSessionId
- );
-
const onError = useCallback(
(e: { message: string }) =>
notifications.toasts.addDanger({
@@ -142,56 +110,13 @@ export function App({
Boolean(
// Temporarily required until the 'by value' paradigm is default.
dashboardFeatureFlag.allowByValueEmbeddables &&
- state.isLinkedToOriginatingApp &&
+ appState.isLinkedToOriginatingApp &&
!(initialInput as LensByReferenceInput)?.savedObjectId
),
- [dashboardFeatureFlag.allowByValueEmbeddables, state.isLinkedToOriginatingApp, initialInput]
+ [dashboardFeatureFlag.allowByValueEmbeddables, appState.isLinkedToOriginatingApp, initialInput]
);
useEffect(() => {
- // Clear app-specific filters when navigating to Lens. Necessary because Lens
- // can be loaded without a full page refresh. If the user navigates to Lens from Discover
- // we keep the filters
- if (!initialContext) {
- data.query.filterManager.setAppFilters([]);
- }
-
- const filterSubscription = data.query.filterManager.getUpdates$().subscribe({
- next: () => {
- setState((s) => ({
- ...s,
- filters: data.query.filterManager.getFilters(),
- searchSessionId: startSession(),
- }));
- trackUiEvent('app_filters_updated');
- },
- });
-
- const timeSubscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({
- next: () => {
- setState((s) => ({
- ...s,
- searchSessionId: startSession(),
- }));
- },
- });
-
- const autoRefreshSubscription = data.query.timefilter.timefilter
- .getAutoRefreshFetch$()
- .pipe(
- tap(() => {
- setState((s) => ({
- ...s,
- searchSessionId: startSession(),
- }));
- }),
- switchMap((done) =>
- // best way in lens to estimate that all panels are updated is to rely on search session service state
- waitUntilNextSessionCompletes$(data.search.session).pipe(finalize(done))
- )
- )
- .subscribe();
-
const kbnUrlStateStorage = createKbnUrlStateStorage({
history,
useHash: uiSettings.get('state:storeInSessionStorage'),
@@ -202,41 +127,10 @@ export function App({
kbnUrlStateStorage
);
- const sessionSubscription = data.search.session
- .getSession$()
- // wait for a tick to filter/timerange subscribers the chance to update the session id in the state
- .pipe(delay(0))
- // then update if it didn't get updated yet
- .subscribe((newSessionId) => {
- if (newSessionId) {
- setState((prevState) => {
- if (prevState.searchSessionId !== newSessionId) {
- return { ...prevState, searchSessionId: newSessionId };
- } else {
- return prevState;
- }
- });
- }
- });
-
return () => {
stopSyncingQueryServiceStateWithUrl();
- filterSubscription.unsubscribe();
- timeSubscription.unsubscribe();
- autoRefreshSubscription.unsubscribe();
- sessionSubscription.unsubscribe();
};
- }, [
- data.query.filterManager,
- data.query.timefilter.timefilter,
- data.search.session,
- notifications.toasts,
- uiSettings,
- data.query,
- history,
- initialContext,
- startSession,
- ]);
+ }, [data.search.session, notifications.toasts, uiSettings, data.query, history]);
useEffect(() => {
onAppLeave((actions) => {
@@ -244,11 +138,11 @@ export function App({
// or when the user has configured something without saving
if (
application.capabilities.visualize.save &&
- !_.isEqual(
- state.persistedDoc?.state,
+ !isEqual(
+ appState.persistedDoc?.state,
getLastKnownDocWithoutPinnedFilters(lastKnownDoc)?.state
) &&
- (state.isSaveable || state.persistedDoc)
+ (appState.isSaveable || appState.persistedDoc)
) {
return actions.confirm(
i18n.translate('xpack.lens.app.unsavedWorkMessage', {
@@ -265,8 +159,8 @@ export function App({
}, [
onAppLeave,
lastKnownDoc,
- state.isSaveable,
- state.persistedDoc,
+ appState.isSaveable,
+ appState.persistedDoc,
application.capabilities.visualize.save,
]);
@@ -274,7 +168,7 @@ export function App({
useEffect(() => {
const isByValueMode = getIsByValueMode();
const breadcrumbs: EuiBreadcrumb[] = [];
- if (state.isLinkedToOriginatingApp && getOriginatingAppName() && redirectToOrigin) {
+ if (appState.isLinkedToOriginatingApp && getOriginatingAppName() && redirectToOrigin) {
breadcrumbs.push({
onClick: () => {
redirectToOrigin();
@@ -297,113 +191,31 @@ export function App({
let currentDocTitle = i18n.translate('xpack.lens.breadcrumbsCreate', {
defaultMessage: 'Create',
});
- if (state.persistedDoc) {
+ if (appState.persistedDoc) {
currentDocTitle = isByValueMode
? i18n.translate('xpack.lens.breadcrumbsByValue', { defaultMessage: 'Edit visualization' })
- : state.persistedDoc.title;
+ : appState.persistedDoc.title;
}
breadcrumbs.push({ text: currentDocTitle });
chrome.setBreadcrumbs(breadcrumbs);
}, [
dashboardFeatureFlag.allowByValueEmbeddables,
- state.isLinkedToOriginatingApp,
getOriginatingAppName,
- state.persistedDoc,
redirectToOrigin,
getIsByValueMode,
- initialInput,
application,
chrome,
- ]);
-
- useEffect(() => {
- if (
- !initialInput ||
- (attributeService.inputIsRefType(initialInput) &&
- initialInput.savedObjectId === state.persistedDoc?.savedObjectId)
- ) {
- return;
- }
-
- setState((s) => ({ ...s, isLoading: true }));
- attributeService
- .unwrapAttributes(initialInput)
- .then((attributes) => {
- if (!initialInput) {
- return;
- }
- const doc = {
- ...initialInput,
- ...attributes,
- type: LENS_EMBEDDABLE_TYPE,
- };
-
- if (attributeService.inputIsRefType(initialInput)) {
- chrome.recentlyAccessed.add(
- getFullPath(initialInput.savedObjectId),
- attributes.title,
- initialInput.savedObjectId
- );
- }
- const indexPatternIds = _.uniq(
- doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)
- );
- getAllIndexPatterns(indexPatternIds, data.indexPatterns)
- .then(({ indexPatterns }) => {
- // Don't overwrite any pinned filters
- data.query.filterManager.setAppFilters(
- injectFilterReferences(doc.state.filters, doc.references)
- );
- setState((s) => ({
- ...s,
- isLoading: false,
- ...(!_.isEqual(state.persistedDoc, doc) ? { persistedDoc: doc } : null),
- lastKnownDoc: doc,
- query: doc.state.query,
- indexPatternsForTopNav: indexPatterns,
- }));
- })
- .catch((e) => {
- setState((s) => ({ ...s, isLoading: false }));
- redirectTo();
- });
- })
- .catch((e) => {
- setState((s) => ({ ...s, isLoading: false }));
- notifications.toasts.addDanger(
- i18n.translate('xpack.lens.app.docLoadingError', {
- defaultMessage: 'Error loading saved document',
- })
- );
-
- redirectTo();
- });
- }, [
- notifications,
- data.indexPatterns,
- data.query.filterManager,
initialInput,
- attributeService,
- redirectTo,
- chrome.recentlyAccessed,
- state.persistedDoc,
+ appState.isLinkedToOriginatingApp,
+ appState.persistedDoc,
]);
const tagsIds =
- state.persistedDoc && savedObjectsTagging
- ? savedObjectsTagging.ui.getTagIdsFromReferences(state.persistedDoc.references)
+ appState.persistedDoc && savedObjectsTagging
+ ? savedObjectsTagging.ui.getTagIdsFromReferences(appState.persistedDoc.references)
: [];
- const runSave = async (
- saveProps: Omit & {
- returnToOrigin: boolean;
- dashboardId?: string | null;
- onTitleDuplicate?: OnSaveProps['onTitleDuplicate'];
- newDescription?: string;
- newTags?: string[];
- },
- options: { saveToLibrary: boolean }
- ) => {
+ const runSave: RunSave = async (saveProps, options) => {
if (!lastKnownDoc) {
return;
}
@@ -502,10 +314,8 @@ export function App({
docToSave.title,
newInput.savedObjectId
);
- setState((s) => ({
- ...s,
- isLinkedToOriginatingApp: false,
- }));
+
+ dispatchSetState({ isLinkedToOriginatingApp: false });
setIsSaveModalVisible(false);
// remove editor state so the connection is still broken after reload
@@ -519,12 +329,12 @@ export function App({
...docToSave,
...newInput,
};
- setState((s) => ({
- ...s,
+
+ dispatchSetState({
+ isLinkedToOriginatingApp: false,
persistedDoc: newDoc,
lastKnownDoc: newDoc,
- isLinkedToOriginatingApp: false,
- }));
+ });
setIsSaveModalVisible(false);
} catch (e) {
@@ -535,187 +345,37 @@ export function App({
}
};
- const lastKnownDocRef = useRef(state.lastKnownDoc);
- lastKnownDocRef.current = state.lastKnownDoc;
-
- const activeDataRef = useRef(state.activeData);
- activeDataRef.current = state.activeData;
-
- const { TopNavMenu } = navigation.ui;
-
const savingToLibraryPermitted = Boolean(
- state.isSaveable && application.capabilities.visualize.save
- );
- const savingToDashboardPermitted = Boolean(
- state.isSaveable && application.capabilities.dashboard?.showWriteControls
+ appState.isSaveable && application.capabilities.visualize.save
);
- const unsavedTitle = i18n.translate('xpack.lens.app.unsavedFilename', {
- defaultMessage: 'unsaved',
- });
- const topNavConfig = getLensTopNavConfig({
- showSaveAndReturn: Boolean(
- state.isLinkedToOriginatingApp &&
- // Temporarily required until the 'by value' paradigm is default.
- (dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput))
- ),
- enableExportToCSV: Boolean(
- state.isSaveable && state.activeData && Object.keys(state.activeData).length
- ),
- isByValueMode: getIsByValueMode(),
- allowByValue: dashboardFeatureFlag.allowByValueEmbeddables,
- showCancel: Boolean(state.isLinkedToOriginatingApp),
- savingToLibraryPermitted,
- savingToDashboardPermitted,
- actions: {
- exportToCSV: () => {
- if (!state.activeData) {
- return;
- }
- const datatables = Object.values(state.activeData);
- const content = datatables.reduce>(
- (memo, datatable, i) => {
- // skip empty datatables
- if (datatable) {
- const postFix = datatables.length > 1 ? `-${i + 1}` : '';
-
- memo[`${lastKnownDoc?.title || unsavedTitle}${postFix}.csv`] = {
- content: exporters.datatableToCSV(datatable, {
- csvSeparator: uiSettings.get('csv:separator', ','),
- quoteValues: uiSettings.get('csv:quoteValues', true),
- formatFactory: data.fieldFormats.deserialize,
- }),
- type: exporters.CSV_MIME_TYPE,
- };
- }
- return memo;
- },
- {}
- );
- if (content) {
- downloadMultipleAs(content);
- }
- },
- saveAndReturn: () => {
- if (savingToDashboardPermitted && lastKnownDoc) {
- // disabling the validation on app leave because the document has been saved.
- onAppLeave((actions) => {
- return actions.default();
- });
- runSave(
- {
- newTitle: lastKnownDoc.title,
- newCopyOnSave: false,
- isTitleDuplicateConfirmed: false,
- returnToOrigin: true,
- },
- {
- saveToLibrary:
- (initialInput && attributeService.inputIsRefType(initialInput)) ?? false,
- }
- );
- }
- },
- showSaveModal: () => {
- if (savingToDashboardPermitted || savingToLibraryPermitted) {
- setIsSaveModalVisible(true);
- }
- },
- cancel: () => {
- if (redirectToOrigin) {
- redirectToOrigin();
- }
- },
- },
- });
-
return (
<>
- {
- const { dateRange, query } = payload;
- const currentRange = data.query.timefilter.timefilter.getTime();
- if (dateRange.from !== currentRange.from || dateRange.to !== currentRange.to) {
- data.query.timefilter.timefilter.setTime(dateRange);
- trackUiEvent('app_date_change');
- } else {
- // Query has changed, renew the session id.
- // Time change will be picked up by the time subscription
- setState((s) => ({
- ...s,
- searchSessionId: startSession(),
- }));
- trackUiEvent('app_query_change');
- }
- setState((s) => ({
- ...s,
- query: query || s.query,
- }));
- }}
- onSaved={(savedQuery) => {
- setState((s) => ({ ...s, savedQuery }));
- }}
- onSavedQueryUpdated={(savedQuery) => {
- const savedQueryFilters = savedQuery.attributes.filters || [];
- const globalFilters = data.query.filterManager.getGlobalFilters();
- data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]);
- setState((s) => ({
- ...s,
- savedQuery: { ...savedQuery }, // Shallow query for reference issues
- query: savedQuery.attributes.query,
- }));
- }}
- onClearSavedQuery={() => {
- data.query.filterManager.setFilters(data.query.filterManager.getGlobalFilters());
- setState((s) => ({
- ...s,
- savedQuery: undefined,
- filters: data.query.filterManager.getGlobalFilters(),
- query: data.query.queryString.getDefaultQuery(),
- }));
- }}
- query={state.query}
- dateRangeFrom={fromDate}
- dateRangeTo={toDate}
+
- {(!state.isLoading || state.persistedDoc) && (
+ {(!appState.isAppLoading || appState.persistedDoc) && (
)}
Toast;
showNoDataPopover: () => void;
initialContext: VisualizeFieldContext | undefined;
- setState: React.Dispatch>;
- data: DataPublicPluginStart;
- lastKnownDoc: React.MutableRefObject;
- activeData: React.MutableRefObject | undefined>;
}) {
const { EditorFrameContainer } = editorFrame;
return (
{
- if (isSaveable !== oldIsSaveable) {
- setState((s) => ({ ...s, isSaveable }));
- }
- if (!_.isEqual(persistedDoc, doc) && !_.isEqual(lastKnownDoc.current, doc)) {
- setState((s) => ({ ...s, lastKnownDoc: doc }));
- }
- if (!_.isEqual(activeDataRef.current, activeData)) {
- setState((s) => ({ ...s, activeData }));
- }
-
- // Update the cached index patterns if the user made a change to any of them
- if (
- indexPatternsForTopNav.length !== filterableIndexPatterns.length ||
- filterableIndexPatterns.some(
- (id) => !indexPatternsForTopNav.find((indexPattern) => indexPattern.id === id)
- )
- ) {
- getAllIndexPatterns(filterableIndexPatterns, data.indexPatterns).then(
- ({ indexPatterns }) => {
- if (indexPatterns) {
- setState((s) => ({ ...s, indexPatternsForTopNav: indexPatterns }));
- }
- }
- );
- }
- }}
/>
);
});
-export async function getAllIndexPatterns(
- ids: string[],
- indexPatternsService: IndexPatternsContract
-): Promise<{ indexPatterns: IndexPatternInstance[]; rejectedIds: string[] }> {
- const responses = await Promise.allSettled(ids.map((id) => indexPatternsService.get(id)));
- const fullfilled = responses.filter(
- (response): response is PromiseFulfilledResult =>
- response.status === 'fulfilled'
- );
- const rejectedIds = responses
- .map((_response, i) => ids[i])
- .filter((id, i) => responses[i].status === 'rejected');
- // return also the rejected ids in case we want to show something later on
- return { indexPatterns: fullfilled.map((response) => response.value), rejectedIds };
-}
-
function getLastKnownDocWithoutPinnedFilters(doc?: Document) {
if (!doc) return undefined;
- const [pinnedFilters, appFilters] = _.partition(
+ const [pinnedFilters, appFilters] = partition(
injectFilterReferences(doc.state?.filters || [], doc.references),
esFilters.isFilterPinned
);
diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx
index f90a21b2818d4..245e964bbd2e6 100644
--- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx
+++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx
@@ -5,11 +5,25 @@
* 2.0.
*/
+import { isEqual } from 'lodash';
import { i18n } from '@kbn/i18n';
+import React from 'react';
import { TopNavMenuData } from '../../../../../src/plugins/navigation/public';
-import { LensTopNavActions } from './types';
+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';
-export function getLensTopNavConfig(options: {
+import { useKibana } from '../../../../../src/plugins/kibana_react/public';
+import {
+ setState as setAppState,
+ useLensSelector,
+ useLensDispatch,
+ LensAppState,
+ DispatchSetState,
+} from '../state_management';
+
+function getLensTopNavConfig(options: {
showSaveAndReturn: boolean;
enableExportToCSV: boolean;
showCancel: boolean;
@@ -101,6 +115,185 @@ export function getLensTopNavConfig(options: {
}),
});
}
-
return topNavMenu;
}
+
+export const LensTopNavMenu = ({
+ setHeaderActionMenu,
+ initialInput,
+ indicateNoData,
+ setIsSaveModalVisible,
+ getIsByValueMode,
+ runSave,
+ onAppLeave,
+ redirectToOrigin,
+}: LensTopNavMenuProps) => {
+ const {
+ data,
+ navigation,
+ uiSettings,
+ application,
+ attributeService,
+ dashboardFeatureFlag,
+ } = useKibana().services;
+
+ const dispatch = useLensDispatch();
+ const dispatchSetState: DispatchSetState = React.useCallback(
+ (state: Partial) => dispatch(setAppState(state)),
+ [dispatch]
+ );
+
+ const {
+ isSaveable,
+ isLinkedToOriginatingApp,
+ indexPatternsForTopNav,
+ query,
+ lastKnownDoc,
+ activeData,
+ savedQuery,
+ } = useLensSelector((state) => state.app);
+
+ const { TopNavMenu } = navigation.ui;
+ const { from, to } = data.query.timefilter.timefilter.getTime();
+
+ const savingToLibraryPermitted = Boolean(isSaveable && application.capabilities.visualize.save);
+ const savingToDashboardPermitted = Boolean(
+ isSaveable && application.capabilities.dashboard?.showWriteControls
+ );
+
+ const unsavedTitle = i18n.translate('xpack.lens.app.unsavedFilename', {
+ defaultMessage: 'unsaved',
+ });
+ const topNavConfig = getLensTopNavConfig({
+ showSaveAndReturn: Boolean(
+ isLinkedToOriginatingApp &&
+ // Temporarily required until the 'by value' paradigm is default.
+ (dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput))
+ ),
+ enableExportToCSV: Boolean(isSaveable && activeData && Object.keys(activeData).length),
+ isByValueMode: getIsByValueMode(),
+ allowByValue: dashboardFeatureFlag.allowByValueEmbeddables,
+ showCancel: Boolean(isLinkedToOriginatingApp),
+ savingToLibraryPermitted,
+ savingToDashboardPermitted,
+ actions: {
+ exportToCSV: () => {
+ if (!activeData) {
+ return;
+ }
+ const datatables = Object.values(activeData);
+ const content = datatables.reduce>(
+ (memo, datatable, i) => {
+ // skip empty datatables
+ if (datatable) {
+ const postFix = datatables.length > 1 ? `-${i + 1}` : '';
+
+ memo[`${lastKnownDoc?.title || unsavedTitle}${postFix}.csv`] = {
+ content: exporters.datatableToCSV(datatable, {
+ csvSeparator: uiSettings.get('csv:separator', ','),
+ quoteValues: uiSettings.get('csv:quoteValues', true),
+ formatFactory: data.fieldFormats.deserialize,
+ }),
+ type: exporters.CSV_MIME_TYPE,
+ };
+ }
+ return memo;
+ },
+ {}
+ );
+ if (content) {
+ downloadMultipleAs(content);
+ }
+ },
+ saveAndReturn: () => {
+ if (savingToDashboardPermitted && lastKnownDoc) {
+ // disabling the validation on app leave because the document has been saved.
+ onAppLeave((actions) => {
+ return actions.default();
+ });
+ runSave(
+ {
+ newTitle: lastKnownDoc.title,
+ newCopyOnSave: false,
+ isTitleDuplicateConfirmed: false,
+ returnToOrigin: true,
+ },
+ {
+ saveToLibrary:
+ (initialInput && attributeService.inputIsRefType(initialInput)) ?? false,
+ }
+ );
+ }
+ },
+ showSaveModal: () => {
+ if (savingToDashboardPermitted || savingToLibraryPermitted) {
+ setIsSaveModalVisible(true);
+ }
+ },
+ cancel: () => {
+ if (redirectToOrigin) {
+ redirectToOrigin();
+ }
+ },
+ },
+ });
+
+ return (
+ {
+ const { dateRange, query: newQuery } = payload;
+ const currentRange = data.query.timefilter.timefilter.getTime();
+ if (dateRange.from !== currentRange.from || dateRange.to !== currentRange.to) {
+ data.query.timefilter.timefilter.setTime(dateRange);
+ trackUiEvent('app_date_change');
+ } else {
+ // Query has changed, renew the session id.
+ // Time change will be picked up by the time subscription
+ dispatchSetState({ searchSessionId: data.search.session.start() });
+ trackUiEvent('app_query_change');
+ }
+ if (newQuery) {
+ if (!isEqual(newQuery, query)) {
+ dispatchSetState({ query: newQuery });
+ }
+ }
+ }}
+ onSaved={(newSavedQuery) => {
+ dispatchSetState({ savedQuery: newSavedQuery });
+ }}
+ onSavedQueryUpdated={(newSavedQuery) => {
+ const savedQueryFilters = newSavedQuery.attributes.filters || [];
+ const globalFilters = data.query.filterManager.getGlobalFilters();
+ data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]);
+ dispatchSetState({
+ query: newSavedQuery.attributes.query,
+ savedQuery: { ...newSavedQuery },
+ }); // Shallow query for reference issues
+ }}
+ onClearSavedQuery={() => {
+ data.query.filterManager.setFilters(data.query.filterManager.getGlobalFilters());
+ dispatchSetState({
+ filters: data.query.filterManager.getGlobalFilters(),
+ query: data.query.queryString.getDefaultQuery(),
+ savedQuery: undefined,
+ });
+ }}
+ indexPatterns={indexPatternsForTopNav}
+ query={query}
+ dateRangeFrom={from}
+ dateRangeTo={to}
+ indicateNoData={indicateNoData}
+ showSearchBar={true}
+ showDatePicker={true}
+ showQueryBar={true}
+ showFilterBar={true}
+ data-test-subj="lnsApp_topNav"
+ screenTitle={'lens'}
+ appName={'lens'}
+ />
+ );
+};
diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.test.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.test.tsx
new file mode 100644
index 0000000000000..f2640c5c32acf
--- /dev/null
+++ b/x-pack/plugins/lens/public/app_plugin/mounter.test.tsx
@@ -0,0 +1,150 @@
+/*
+ * 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 { makeDefaultServices, mockLensStore } from '../mocks';
+import { act } from 'react-dom/test-utils';
+import { loadDocument } from './mounter';
+import { LensEmbeddableInput } from '../editor_frame_service/embeddable/embeddable';
+
+const defaultSavedObjectId = '1234';
+
+describe('Mounter', () => {
+ describe('loadDocument', () => {
+ 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);
+ 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' }],
+ });
+
+ const lensStore = await mockLensStore({ data: services.data });
+ await act(async () => {
+ await loadDocument(
+ redirectCallback,
+ { savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
+ services,
+ lensStore
+ );
+ });
+
+ expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
+ 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' } } }],
+ }),
+ }),
+ }),
+ });
+ });
+
+ 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 });
+
+ await act(async () => {
+ await loadDocument(
+ redirectCallback,
+ { savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
+ services,
+ lensStore
+ );
+ });
+
+ await act(async () => {
+ await loadDocument(
+ redirectCallback,
+ { savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
+ services,
+ lensStore
+ );
+ });
+
+ expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await loadDocument(
+ redirectCallback,
+ { savedObjectId: '5678' } as LensEmbeddableInput,
+ services,
+ lensStore
+ );
+ });
+
+ expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(2);
+ });
+
+ it('handles document load errors', async () => {
+ const services = makeDefaultServices();
+ const redirectCallback = jest.fn();
+
+ const lensStore = mockLensStore({ data: services.data });
+
+ services.attributeService.unwrapAttributes = jest.fn().mockRejectedValue('failed to load');
+
+ await act(async () => {
+ await loadDocument(
+ redirectCallback,
+ { savedObjectId: defaultSavedObjectId } as LensEmbeddableInput,
+ services,
+ lensStore
+ );
+ });
+ expect(services.attributeService.unwrapAttributes).toHaveBeenCalledWith({
+ savedObjectId: defaultSavedObjectId,
+ });
+ expect(services.notifications.toasts.addDanger).toHaveBeenCalled();
+ expect(redirectCallback).toHaveBeenCalled();
+ });
+
+ it('adds to the recently accessed list on load', async () => {
+ const redirectCallback = jest.fn();
+
+ const services = makeDefaultServices();
+ const lensStore = mockLensStore({ data: services.data });
+ await act(async () => {
+ await loadDocument(
+ redirectCallback,
+ ({ savedObjectId: defaultSavedObjectId } as unknown) as LensEmbeddableInput,
+ services,
+ lensStore
+ );
+ });
+
+ expect(services.chrome.recentlyAccessed.add).toHaveBeenCalledWith(
+ '/app/lens#/edit/1234',
+ 'An extremely cool default document!',
+ '1234'
+ );
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx
index e6eb115562d37..708573e843fcf 100644
--- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx
+++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx
@@ -15,6 +15,8 @@ import { render, unmountComponentAtNode } from 'react-dom';
import { i18n } from '@kbn/i18n';
import { DashboardFeatureFlagConfig } from 'src/plugins/dashboard/public';
+import { Provider } from 'react-redux';
+import { uniq, isEqual } from 'lodash';
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
import { LensReportManager, setReportManager, trackUiEvent } from '../lens_ui_telemetry';
@@ -23,7 +25,7 @@ import { App } from './app';
import { EditorFrameStart } from '../types';
import { addHelpMenuToAppChrome } from '../help_menu_util';
import { LensPluginStartDependencies } from '../plugin';
-import { LENS_EMBEDDABLE_TYPE, LENS_EDIT_BY_VALUE, APP_ID } from '../../common';
+import { LENS_EMBEDDABLE_TYPE, LENS_EDIT_BY_VALUE, APP_ID, getFullPath } from '../../common';
import {
LensEmbeddableInput,
LensByReferenceInput,
@@ -34,6 +36,16 @@ import { LensAttributeService } from '../lens_attribute_service';
import { LensAppServices, RedirectToOriginProps, HistoryLocationState } from './types';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
+import {
+ makeConfigureStore,
+ navigateAway,
+ getPreloadedState,
+ LensRootStore,
+ setState,
+} from '../state_management';
+import { getAllIndexPatterns, getResolvedDateRange } from '../utils';
+import { injectFilterReferences } from '../persistence';
+
export async function mountApp(
core: CoreSetup,
params: AppMountParameters,
@@ -149,8 +161,32 @@ export async function mountApp(
coreStart.application.navigateToApp(embeddableEditorIncomingState?.originatingApp);
}
};
+ const initialContext =
+ historyLocationState && historyLocationState.type === ACTION_VISUALIZE_LENS_FIELD
+ ? historyLocationState.payload
+ : undefined;
+
+ // Clear app-specific filters when navigating to Lens. Necessary because Lens
+ // can be loaded without a full page refresh. If the user navigates to Lens from Discover
+ // we keep the filters
+ if (!initialContext) {
+ data.query.filterManager.setAppFilters([]);
+ }
+
+ const preloadedState = getPreloadedState({
+ 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)
+ filters: !initialContext
+ ? data.query.filterManager.getGlobalFilters()
+ : data.query.filterManager.getFilters(),
+ searchSessionId: data.search.session.start(),
+ resolvedDateRange: getResolvedDateRange(data.query.timefilter.timefilter),
+ isLinkedToOriginatingApp: Boolean(embeddableEditorIncomingState?.originatingApp),
+ });
+
+ const lensStore: LensRootStore = makeConfigureStore(preloadedState, { data });
- // const featureFlagConfig = await getByValueFeatureFlag();
const EditorRenderer = React.memo(
(props: { id?: string; history: History; editByValue?: boolean }) => {
const redirectCallback = useCallback(
@@ -160,23 +196,23 @@ export async function mountApp(
[props.history]
);
trackUiEvent('loaded');
+ const initialInput = getInitialInput(props.id, props.editByValue);
+ loadDocument(redirectCallback, initialInput, lensServices, lensStore);
return (
-
+
+
+
);
}
);
@@ -232,5 +268,86 @@ export async function mountApp(
data.search.session.clear();
unmountComponentAtNode(params.element);
unlistenParentHistory();
+ lensStore.dispatch(navigateAway());
};
}
+
+export function loadDocument(
+ redirectCallback: (savedObjectId?: string) => void,
+ initialInput: LensEmbeddableInput | undefined,
+ lensServices: LensAppServices,
+ lensStore: LensRootStore
+) {
+ const { attributeService, chrome, notifications, data } = lensServices;
+ const { persistedDoc } = lensStore.getState().app;
+ if (
+ !initialInput ||
+ (attributeService.inputIsRefType(initialInput) &&
+ initialInput.savedObjectId === persistedDoc?.savedObjectId)
+ ) {
+ return;
+ }
+ lensStore.dispatch(setState({ isAppLoading: true }));
+
+ attributeService
+ .unwrapAttributes(initialInput)
+ .then((attributes) => {
+ if (!initialInput) {
+ return;
+ }
+ const doc = {
+ ...initialInput,
+ ...attributes,
+ type: LENS_EMBEDDABLE_TYPE,
+ };
+
+ if (attributeService.inputIsRefType(initialInput)) {
+ chrome.recentlyAccessed.add(
+ getFullPath(initialInput.savedObjectId),
+ attributes.title,
+ initialInput.savedObjectId
+ );
+ }
+ const indexPatternIds = uniq(
+ doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)
+ );
+ getAllIndexPatterns(indexPatternIds, data.indexPatterns)
+ .then(({ indexPatterns }) => {
+ // Don't overwrite any pinned filters
+ data.query.filterManager.setAppFilters(
+ injectFilterReferences(doc.state.filters, doc.references)
+ );
+ lensStore.dispatch(
+ setState({
+ query: doc.state.query,
+ isAppLoading: false,
+ indexPatternsForTopNav: indexPatterns,
+ lastKnownDoc: doc,
+ ...(!isEqual(persistedDoc, doc) ? { persistedDoc: doc } : null),
+ })
+ );
+ })
+ .catch((e) => {
+ lensStore.dispatch(
+ setState({
+ isAppLoading: false,
+ })
+ );
+ redirectCallback();
+ });
+ })
+ .catch((e) => {
+ lensStore.dispatch(
+ setState({
+ isAppLoading: false,
+ })
+ );
+ notifications.toasts.addDanger(
+ i18n.translate('xpack.lens.app.docLoadingError', {
+ defaultMessage: 'Error loading saved document',
+ })
+ );
+
+ redirectCallback();
+ });
+}
diff --git a/x-pack/plugins/lens/public/app_plugin/time_range.ts b/x-pack/plugins/lens/public/app_plugin/time_range.ts
deleted file mode 100644
index c9e507f3e6f13..0000000000000
--- a/x-pack/plugins/lens/public/app_plugin/time_range.ts
+++ /dev/null
@@ -1,84 +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 './app.scss';
-
-import _ from 'lodash';
-import moment from 'moment';
-import { useEffect, useMemo } from 'react';
-import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
-import { LensAppState } from './types';
-import { Document } from '../persistence';
-
-function containsDynamicMath(dateMathString: string) {
- return dateMathString.includes('now');
-}
-
-const TIME_LAG_PERCENTAGE_LIMIT = 0.02;
-
-/**
- * Fetches the current global time range from data plugin and restarts session
- * if the fixed "now" parameter is diverging too much from the actual current time.
- * @param data data plugin contract to manage current now value, time range and session
- * @param lastKnownDoc Current state of the editor
- * @param setState state setter for Lens app state
- * @param searchSessionId current session id
- */
-export function useTimeRange(
- data: DataPublicPluginStart,
- lastKnownDoc: Document | undefined,
- setState: React.Dispatch>,
- searchSessionId: string
-) {
- const timefilter = data.query.timefilter.timefilter;
- const { from, to } = data.query.timefilter.timefilter.getTime();
-
- // Need a stable reference for the frame component of the dateRange
- const resolvedDateRange = useMemo(() => {
- const { min, max } = timefilter.calculateBounds({
- from,
- to,
- });
- return { fromDate: min?.toISOString() || from, toDate: max?.toISOString() || to };
- // recalculate current date range if the session gets updated because it
- // might change "now" and calculateBounds depends on it internally
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [timefilter, searchSessionId, from, to]);
-
- useEffect(() => {
- const unresolvedTimeRange = timefilter.getTime();
- if (
- !containsDynamicMath(unresolvedTimeRange.from) &&
- !containsDynamicMath(unresolvedTimeRange.to)
- ) {
- return;
- }
-
- const { min, max } = timefilter.getBounds();
-
- if (!min || !max) {
- // bounds not fully specified, bailing out
- return;
- }
-
- // calculate length of currently configured range in ms
- const timeRangeLength = moment.duration(max.diff(min)).asMilliseconds();
-
- // calculate lag of managed "now" for date math
- const nowDiff = Date.now() - data.nowProvider.get().valueOf();
-
- // if the lag is signifcant, start a new session to clear the cache
- if (nowDiff > timeRangeLength * TIME_LAG_PERCENTAGE_LIMIT) {
- setState((s) => ({
- ...s,
- searchSessionId: data.search.session.start(),
- }));
- }
- }, [data.nowProvider, data.search.session, timefilter, lastKnownDoc, setState]);
-
- return { resolvedDateRange, from, to };
-}
diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts
index c9143542e67bf..72850552723f3 100644
--- a/x-pack/plugins/lens/public/app_plugin/types.ts
+++ b/x-pack/plugins/lens/public/app_plugin/types.ts
@@ -6,6 +6,7 @@
*/
import { History } from 'history';
+import { OnSaveProps } from 'src/plugins/saved_objects/public';
import {
ApplicationStart,
AppMountParameters,
@@ -16,14 +17,7 @@ import {
OverlayStart,
SavedObjectsStart,
} from '../../../../../src/core/public';
-import {
- DataPublicPluginStart,
- Filter,
- IndexPattern,
- Query,
- SavedQuery,
-} from '../../../../../src/plugins/data/public';
-import { Document } from '../persistence';
+import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { LensEmbeddableInput } from '../editor_frame_service/embeddable/embeddable';
import { NavigationPublicPluginStart } from '../../../../../src/plugins/navigation/public';
import { LensAttributeService } from '../lens_attribute_service';
@@ -38,28 +32,7 @@ import {
EmbeddableEditorState,
EmbeddableStateTransfer,
} from '../../../../../src/plugins/embeddable/public';
-import { TableInspectorAdapter } from '../editor_frame_service/types';
import { EditorFrameInstance } from '../types';
-
-export interface LensAppState {
- isLoading: boolean;
- 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;
-
- query: Query;
- filters: Filter[];
- savedQuery?: SavedQuery;
- isSaveable: boolean;
- activeData?: TableInspectorAdapter;
- searchSessionId: string;
-}
-
export interface RedirectToOriginProps {
input?: LensEmbeddableInput;
isCopied?: boolean;
@@ -82,6 +55,32 @@ export interface LensAppProps {
initialContext?: VisualizeFieldContext;
}
+export type RunSave = (
+ saveProps: Omit & {
+ returnToOrigin: boolean;
+ dashboardId?: string | null;
+ onTitleDuplicate?: OnSaveProps['onTitleDuplicate'];
+ newDescription?: string;
+ newTags?: string[];
+ },
+ options: {
+ saveToLibrary: boolean;
+ }
+) => Promise;
+
+export interface LensTopNavMenuProps {
+ onAppLeave: AppMountParameters['onAppLeave'];
+ setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
+
+ redirectToOrigin?: (props?: RedirectToOriginProps) => void;
+ // The initial input passed in by the container when editing. Can be either by reference or by value.
+ initialInput?: LensEmbeddableInput;
+ getIsByValueMode: () => boolean;
+ indicateNoData: boolean;
+ setIsSaveModalVisible: React.Dispatch>;
+ runSave: RunSave;
+}
+
export interface HistoryLocationState {
type: typeof ACTION_VISUALIZE_LENS_FIELD;
payload: VisualizeFieldContext;
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx
index f23e4c74e1a8b..351b4009240eb 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx
@@ -7,6 +7,7 @@
import React, { ReactElement } from 'react';
import { ReactWrapper } from 'enzyme';
+import { setState, LensRootStore } from '../../state_management/index';
// Tests are executed in a jsdom environment who does not have sizing methods,
// thus the AutoSizer will always compute a 0x0 size space
@@ -28,8 +29,7 @@ jest.mock('react-virtualized-auto-sizer', () => {
});
import { EuiPanel, EuiToolTip } from '@elastic/eui';
-import { mountWithIntl as mount } from '@kbn/test/jest';
-import { EditorFrame } from './editor_frame';
+import { EditorFrame, EditorFrameProps } from './editor_frame';
import { DatasourcePublicAPI, DatasourceSuggestion, Visualization } from '../../types';
import { act } from 'react-dom/test-utils';
import { coreMock } from 'src/core/public/mocks';
@@ -44,9 +44,9 @@ import { ReactExpressionRendererType } from 'src/plugins/expressions/public';
import { DragDrop } from '../../drag_drop';
import { FrameLayout } from './frame_layout';
import { uiActionsPluginMock } from '../../../../../../src/plugins/ui_actions/public/mocks';
-import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks';
import { chartPluginMock } from '../../../../../../src/plugins/charts/public/mocks';
import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks';
+import { mockDataPlugin, mountWithProvider } from '../../mocks';
function generateSuggestion(state = {}): DatasourceSuggestion {
return {
@@ -62,7 +62,7 @@ function generateSuggestion(state = {}): DatasourceSuggestion {
}
function getDefaultProps() {
- return {
+ const defaultProps = {
store: {
save: jest.fn(),
load: jest.fn(),
@@ -72,18 +72,17 @@ function getDefaultProps() {
onChange: jest.fn(),
dateRange: { fromDate: '', toDate: '' },
query: { query: '', language: 'lucene' },
- filters: [],
core: coreMock.createStart(),
plugins: {
uiActions: uiActionsPluginMock.createStartContract(),
- data: dataPluginMock.createStartContract(),
+ data: mockDataPlugin(),
expressions: expressionsPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
},
palettes: chartPluginMock.createPaletteRegistry(),
showNoDataPopover: jest.fn(),
- searchSessionId: 'sessionId',
};
+ return defaultProps;
}
describe('editor_frame', () => {
@@ -133,85 +132,57 @@ describe('editor_frame', () => {
describe('initialization', () => {
it('should initialize initial datasource', async () => {
mockVisualization.getLayerIds.mockReturnValue([]);
- await act(async () => {
- mount(
-
- );
- });
-
- expect(mockDatasource.initialize).toHaveBeenCalled();
- });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ },
- it('should not initialize datasource and visualization if no initial one is specificed', () => {
- act(() => {
- mount(
-
- );
- });
+ ExpressionRenderer: expressionRendererMock,
+ };
- expect(mockVisualization.initialize).not.toHaveBeenCalled();
- expect(mockDatasource.initialize).not.toHaveBeenCalled();
+ await mountWithProvider(, props.plugins.data);
+ expect(mockDatasource.initialize).toHaveBeenCalled();
});
it('should initialize all datasources with state from doc', async () => {
const mockDatasource3 = createMockDatasource('testDatasource3');
const datasource1State = { datasource1: '' };
const datasource2State = { datasource2: '' };
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ testDatasource2: mockDatasource2,
+ testDatasource3: mockDatasource3,
+ },
- await act(async () => {
- mount(
-
- );
+ ExpressionRenderer: expressionRendererMock,
+ };
+
+ await mountWithProvider(, props.plugins.data, {
+ persistedDoc: {
+ visualizationType: 'testVis',
+ title: '',
+ state: {
+ datasourceStates: {
+ testDatasource: datasource1State,
+ testDatasource2: datasource2State,
+ },
+ visualization: {},
+ query: { query: '', language: 'lucene' },
+ filters: [],
+ },
+ references: [],
+ },
});
+
expect(mockDatasource.initialize).toHaveBeenCalledWith(datasource1State, [], undefined, {
isFullEditor: true,
});
@@ -222,42 +193,40 @@ describe('editor_frame', () => {
});
it('should not render something before all datasources are initialized', async () => {
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+
await act(async () => {
- mount(
-
- );
+ mountWithProvider(, props.plugins.data);
expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled();
});
expect(mockDatasource.renderDataPanel).toHaveBeenCalled();
});
it('should not initialize visualization before datasource is initialized', async () => {
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+
await act(async () => {
- mount(
-
- );
+ mountWithProvider(, props.plugins.data);
expect(mockVisualization.initialize).not.toHaveBeenCalled();
});
@@ -265,23 +234,19 @@ describe('editor_frame', () => {
});
it('should pass the public frame api into visualization initialize', async () => {
- const defaultProps = getDefaultProps();
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
await act(async () => {
- mount(
-
- );
+ mountWithProvider(, props.plugins.data);
expect(mockVisualization.initialize).not.toHaveBeenCalled();
});
@@ -291,33 +256,43 @@ describe('editor_frame', () => {
removeLayers: expect.any(Function),
query: { query: '', language: 'lucene' },
filters: [],
- dateRange: { fromDate: 'now-7d', toDate: 'now' },
- availablePalettes: defaultProps.palettes,
- searchSessionId: 'sessionId',
+ dateRange: { fromDate: '2021-01-10T04:00:00.000Z', toDate: '2021-01-10T08:00:00.000Z' },
+ availablePalettes: props.palettes,
+ searchSessionId: 'sessionId-1',
});
});
it('should add new layer on active datasource on frame api call', async () => {
const initialState = { datasource2: '' };
mockDatasource2.initialize.mockReturnValue(Promise.resolve(initialState));
- await act(async () => {
- mount(
- , props.plugins.data, {
+ persistedDoc: {
+ visualizationType: 'testVis',
+ title: '',
+ state: {
+ datasourceStates: {
testDatasource2: mockDatasource2,
- }}
- initialDatasourceId="testDatasource2"
- initialVisualizationId="testVis"
- ExpressionRenderer={expressionRendererMock}
- />
- );
+ },
+ visualization: {},
+ query: { query: '', language: 'lucene' },
+ filters: [],
+ },
+ references: [],
+ },
});
-
act(() => {
mockVisualization.initialize.mock.calls[0][0].addNewLayer();
});
@@ -332,22 +307,33 @@ describe('editor_frame', () => {
mockDatasource2.getLayers.mockReturnValue(['abc', 'def']);
mockDatasource2.removeLayer.mockReturnValue({ removed: true });
mockVisualization.getLayerIds.mockReturnValue(['first', 'abc', 'def']);
- await act(async () => {
- mount(
- , props.plugins.data, {
+ persistedDoc: {
+ visualizationType: 'testVis',
+ title: '',
+ state: {
+ datasourceStates: {
testDatasource2: mockDatasource2,
- }}
- initialDatasourceId="testDatasource2"
- initialVisualizationId="testVis"
- ExpressionRenderer={expressionRendererMock}
- />
- );
+ },
+ visualization: {},
+ query: { query: '', language: 'lucene' },
+ filters: [],
+ },
+ references: [],
+ },
});
act(() => {
@@ -362,28 +348,26 @@ describe('editor_frame', () => {
const initialState = {};
let databaseInitialized: ({}) => void;
- await act(async () => {
- mount(
-
- new Promise((resolve) => {
- databaseInitialized = resolve;
- }),
- },
- }}
- initialDatasourceId="testDatasource"
- initialVisualizationId="testVis"
- ExpressionRenderer={expressionRendererMock}
- />
- );
- });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: {
+ ...mockDatasource,
+ initialize: () =>
+ new Promise((resolve) => {
+ databaseInitialized = resolve;
+ }),
+ },
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+
+ await mountWithProvider(, props.plugins.data);
+
await act(async () => {
databaseInitialized!(initialState);
});
@@ -397,25 +381,22 @@ describe('editor_frame', () => {
const initialState = {};
mockDatasource.getLayers.mockReturnValue(['first']);
- await act(async () => {
- mount(
- initialState },
- }}
- datasourceMap={{
- testDatasource: {
- ...mockDatasource,
- initialize: () => Promise.resolve(),
- },
- }}
- initialDatasourceId="testDatasource"
- initialVisualizationId="testVis"
- ExpressionRenderer={expressionRendererMock}
- />
- );
- });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: { ...mockVisualization, initialize: () => initialState },
+ },
+ datasourceMap: {
+ testDatasource: {
+ ...mockDatasource,
+ initialize: () => Promise.resolve(),
+ },
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+
+ await mountWithProvider(, props.plugins.data);
expect(mockVisualization.getConfiguration).toHaveBeenCalledWith(
expect.objectContaining({ state: initialState })
@@ -427,25 +408,21 @@ describe('editor_frame', () => {
it('should render the resulting expression using the expression renderer', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
- await act(async () => {
- instance = mount(
- 'vis' },
- }}
- datasourceMap={{
- testDatasource: {
- ...mockDatasource,
- toExpression: () => 'datasource',
- },
- }}
- initialDatasourceId="testDatasource"
- initialVisualizationId="testVis"
- ExpressionRenderer={expressionRendererMock}
- />
- );
- });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: { ...mockVisualization, toExpression: () => 'vis' },
+ },
+ datasourceMap: {
+ testDatasource: {
+ ...mockDatasource,
+ toExpression: () => 'datasource',
+ },
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+ instance = (await mountWithProvider(, props.plugins.data)).instance;
instance.update();
@@ -466,37 +443,34 @@ describe('editor_frame', () => {
);
mockDatasource2.getLayers.mockReturnValue(['second', 'third']);
- await act(async () => {
- instance = mount(
- 'vis' },
- }}
- datasourceMap={{
- testDatasource: mockDatasource,
- testDatasource2: mockDatasource2,
- }}
- initialDatasourceId="testDatasource"
- initialVisualizationId="testVis"
- ExpressionRenderer={expressionRendererMock}
- doc={{
- visualizationType: 'testVis',
- title: '',
- state: {
- datasourceStates: {
- testDatasource: {},
- testDatasource2: {},
- },
- visualization: {},
- query: { query: '', language: 'lucene' },
- filters: [],
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: { ...mockVisualization, toExpression: () => 'vis' },
+ },
+ datasourceMap: { testDatasource: mockDatasource, testDatasource2: mockDatasource2 },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+
+ instance = (
+ await mountWithProvider(, props.plugins.data, {
+ persistedDoc: {
+ visualizationType: 'testVis',
+ title: '',
+ state: {
+ datasourceStates: {
+ testDatasource: {},
+ testDatasource2: {},
},
- references: [],
- }}
- />
- );
- });
+ visualization: {},
+ query: { query: '', language: 'lucene' },
+ filters: [],
+ },
+ references: [],
+ },
+ })
+ ).instance;
instance.update();
@@ -577,23 +551,18 @@ describe('editor_frame', () => {
describe('state update', () => {
it('should re-render config panel after state update', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ },
- await act(async () => {
- mount(
-
- );
- });
+ ExpressionRenderer: expressionRendererMock,
+ };
+ await mountWithProvider(, props.plugins.data);
const updatedState = {};
const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1]
.setState;
@@ -601,8 +570,9 @@ describe('editor_frame', () => {
setDatasourceState(updatedState);
});
+ // TODO: temporary regression
// validation requires to calls this getConfiguration API
- expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(7);
+ expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(9);
expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith(
expect.objectContaining({
state: updatedState,
@@ -613,22 +583,18 @@ describe('editor_frame', () => {
it('should re-render data panel after state update', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
- await act(async () => {
- mount(
-
- );
- });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+ await mountWithProvider(, props.plugins.data);
const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1]
.setState;
@@ -653,23 +619,18 @@ describe('editor_frame', () => {
it('should re-render config panel with updated datasource api after datasource state update', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ },
- await act(async () => {
- mount(
-
- );
- });
+ ExpressionRenderer: expressionRendererMock,
+ };
+ await mountWithProvider(, props.plugins.data);
const updatedPublicAPI: DatasourcePublicAPI = {
datasourceId: 'testDatasource',
@@ -684,8 +645,9 @@ describe('editor_frame', () => {
setDatasourceState({});
});
+ // TODO: temporary regression, selectors will help
// validation requires to calls this getConfiguration API
- expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(7);
+ expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(9);
expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith(
expect.objectContaining({
frame: expect.objectContaining({
@@ -703,37 +665,33 @@ describe('editor_frame', () => {
mockDatasource.getLayers.mockReturnValue(['first']);
mockDatasource2.getLayers.mockReturnValue(['second', 'third']);
mockVisualization.getLayerIds.mockReturnValue(['first', 'second', 'third']);
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ testDatasource2: mockDatasource2,
+ },
- await act(async () => {
- mount(
-
- );
+ ExpressionRenderer: expressionRendererMock,
+ };
+ await mountWithProvider(, props.plugins.data, {
+ persistedDoc: {
+ visualizationType: 'testVis',
+ title: '',
+ state: {
+ datasourceStates: {
+ testDatasource: {},
+ testDatasource2: {},
+ },
+ visualization: {},
+ query: { query: '', language: 'lucene' },
+ filters: [],
+ },
+ references: [],
+ },
});
expect(mockVisualization.getConfiguration).toHaveBeenCalled();
@@ -756,36 +714,33 @@ describe('editor_frame', () => {
const datasource1State = { datasource1: '' };
const datasource2State = { datasource2: '' };
- await act(async () => {
- mount(
-
- );
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ testDatasource2: mockDatasource2,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+ await mountWithProvider(, props.plugins.data, {
+ persistedDoc: {
+ visualizationType: 'testVis',
+ title: '',
+ state: {
+ datasourceStates: {
+ testDatasource: datasource1State,
+ testDatasource2: datasource2State,
+ },
+ visualization: {},
+ query: { query: '', language: 'lucene' },
+ filters: [],
+ },
+ references: [],
+ },
});
expect(mockDatasource.getPublicAPI).toHaveBeenCalledWith(
@@ -813,22 +768,18 @@ describe('editor_frame', () => {
mockDatasource.initialize.mockResolvedValue(datasourceState);
mockDatasource.getLayers.mockReturnValue(['first']);
- await act(async () => {
- mount(
-
- );
- });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+ await mountWithProvider(, props.plugins.data);
expect(mockDatasource.getPublicAPI).toHaveBeenCalledWith({
state: datasourceState,
@@ -870,24 +821,20 @@ describe('editor_frame', () => {
},
]);
- await act(async () => {
- instance = mount(
-
- );
- });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ testVis2: mockVisualization2,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ testDatasource2: mockDatasource2,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+ instance = (await mountWithProvider(, props.plugins.data)).instance;
// necessary to flush elements to dom synchronously
instance.update();
@@ -984,49 +931,41 @@ describe('editor_frame', () => {
describe('suggestions', () => {
it('should fetch suggestions of currently active datasource when initializes from visualization trigger', async () => {
- await act(async () => {
- mount(
-
- );
- });
+ const props = {
+ ...getDefaultProps(),
+ initialContext: {
+ indexPatternId: '1',
+ fieldName: 'test',
+ },
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ testDatasource2: mockDatasource2,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+ await mountWithProvider(, props.plugins.data);
expect(mockDatasource.getDatasourceSuggestionsForVisualizeField).toHaveBeenCalled();
});
it('should fetch suggestions of currently active datasource', async () => {
- await act(async () => {
- mount(
-
- );
- });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ testDatasource2: mockDatasource2,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+ await mountWithProvider(, props.plugins.data);
expect(mockDatasource.getDatasourceSuggestionsFromCurrentState).toHaveBeenCalled();
expect(mockDatasource2.getDatasourceSuggestionsFromCurrentState).not.toHaveBeenCalled();
@@ -1046,24 +985,20 @@ describe('editor_frame', () => {
},
]);
- await act(async () => {
- mount(
-
- );
- });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ testVis2: mockVisualization2,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ testDatasource2: mockDatasource2,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+ await mountWithProvider(, props.plugins.data);
expect(mockVisualization.getSuggestions).toHaveBeenCalled();
expect(mockVisualization2.getSuggestions).toHaveBeenCalled();
@@ -1072,71 +1007,66 @@ describe('editor_frame', () => {
let instance: ReactWrapper;
it('should display top 5 suggestions in descending order', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
-
- await act(async () => {
- instance = mount(
- [
- {
- score: 0.1,
- state: {},
- title: 'Suggestion6',
- previewIcon: 'empty',
- },
- {
- score: 0.5,
- state: {},
- title: 'Suggestion3',
- previewIcon: 'empty',
- },
- {
- score: 0.7,
- state: {},
- title: 'Suggestion2',
- previewIcon: 'empty',
- },
- {
- score: 0.8,
- state: {},
- title: 'Suggestion1',
- previewIcon: 'empty',
- },
- ],
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: {
+ ...mockVisualization,
+ getSuggestions: () => [
+ {
+ score: 0.1,
+ state: {},
+ title: 'Suggestion6',
+ previewIcon: 'empty',
},
- testVis2: {
- ...mockVisualization,
- getSuggestions: () => [
- {
- score: 0.4,
- state: {},
- title: 'Suggestion5',
- previewIcon: 'empty',
- },
- {
- score: 0.45,
- state: {},
- title: 'Suggestion4',
- previewIcon: 'empty',
- },
- ],
+ {
+ score: 0.5,
+ state: {},
+ title: 'Suggestion3',
+ previewIcon: 'empty',
},
- }}
- datasourceMap={{
- testDatasource: {
- ...mockDatasource,
- getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()],
+ {
+ score: 0.7,
+ state: {},
+ title: 'Suggestion2',
+ previewIcon: 'empty',
},
- }}
- initialDatasourceId="testDatasource"
- initialVisualizationId="testVis"
- ExpressionRenderer={expressionRendererMock}
- />
- );
- });
+ {
+ score: 0.8,
+ state: {},
+ title: 'Suggestion1',
+ previewIcon: 'empty',
+ },
+ ],
+ },
+ testVis2: {
+ ...mockVisualization,
+ getSuggestions: () => [
+ {
+ score: 0.4,
+ state: {},
+ title: 'Suggestion5',
+ previewIcon: 'empty',
+ },
+ {
+ score: 0.45,
+ state: {},
+ title: 'Suggestion4',
+ previewIcon: 'empty',
+ },
+ ],
+ },
+ },
+ datasourceMap: {
+ testDatasource: {
+ ...mockDatasource,
+ getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()],
+ },
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+ instance = (await mountWithProvider(, props.plugins.data)).instance;
// TODO why is this necessary?
instance.update();
@@ -1159,37 +1089,32 @@ describe('editor_frame', () => {
mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']);
const newDatasourceState = {};
const suggestionVisState = {};
-
- await act(async () => {
- instance = mount(
- [
- {
- score: 0.8,
- state: suggestionVisState,
- title: 'Suggestion1',
- previewIcon: 'empty',
- },
- ],
- },
- testVis2: mockVisualization2,
- }}
- datasourceMap={{
- testDatasource: {
- ...mockDatasource,
- getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()],
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: {
+ ...mockVisualization,
+ getSuggestions: () => [
+ {
+ score: 0.8,
+ state: suggestionVisState,
+ title: 'Suggestion1',
+ previewIcon: 'empty',
},
- }}
- initialDatasourceId="testDatasource"
- initialVisualizationId="testVis2"
- ExpressionRenderer={expressionRendererMock}
- />
- );
- });
+ ],
+ },
+ testVis2: mockVisualization2,
+ },
+ datasourceMap: {
+ testDatasource: {
+ ...mockDatasource,
+ getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()],
+ },
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+ instance = (await mountWithProvider(, props.plugins.data)).instance;
// TODO why is this necessary?
instance.update();
@@ -1199,7 +1124,8 @@ describe('editor_frame', () => {
});
// validation requires to calls this getConfiguration API
- expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(5);
+ // TODO: why so many times?
+ expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(10);
expect(mockVisualization.getConfiguration).toHaveBeenCalledWith(
expect.objectContaining({
state: suggestionVisState,
@@ -1216,45 +1142,40 @@ describe('editor_frame', () => {
it('should switch to best suggested visualization on field drop', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const suggestionVisState = {};
-
- await act(async () => {
- instance = mount(
- [
- {
- score: 0.2,
- state: {},
- title: 'Suggestion1',
- previewIcon: 'empty',
- },
- {
- score: 0.8,
- state: suggestionVisState,
- title: 'Suggestion2',
- previewIcon: 'empty',
- },
- ],
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: {
+ ...mockVisualization,
+ getSuggestions: () => [
+ {
+ score: 0.2,
+ state: {},
+ title: 'Suggestion1',
+ previewIcon: 'empty',
},
- testVis2: mockVisualization2,
- }}
- datasourceMap={{
- testDatasource: {
- ...mockDatasource,
- getDatasourceSuggestionsForField: () => [generateSuggestion()],
- getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()],
- getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()],
+ {
+ score: 0.8,
+ state: suggestionVisState,
+ title: 'Suggestion2',
+ previewIcon: 'empty',
},
- }}
- initialDatasourceId="testDatasource"
- initialVisualizationId="testVis"
- ExpressionRenderer={expressionRendererMock}
- />
- );
- });
+ ],
+ },
+ testVis2: mockVisualization2,
+ },
+ datasourceMap: {
+ testDatasource: {
+ ...mockDatasource,
+ getDatasourceSuggestionsForField: () => [generateSuggestion()],
+ getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()],
+ getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()],
+ },
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ };
+ instance = (await mountWithProvider(, props.plugins.data)).instance;
// TODO why is this necessary?
instance.update();
@@ -1274,63 +1195,58 @@ describe('editor_frame', () => {
mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']);
const suggestionVisState = {};
- await act(async () => {
- instance = mount(
- [
- {
- score: 0.2,
- state: {},
- title: 'Suggestion1',
- previewIcon: 'empty',
- },
- {
- score: 0.6,
- state: {},
- title: 'Suggestion2',
- previewIcon: 'empty',
- },
- ],
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: {
+ ...mockVisualization,
+ getSuggestions: () => [
+ {
+ score: 0.2,
+ state: {},
+ title: 'Suggestion1',
+ previewIcon: 'empty',
},
- testVis2: {
- ...mockVisualization2,
- getSuggestions: () => [
- {
- score: 0.8,
- state: suggestionVisState,
- title: 'Suggestion3',
- previewIcon: 'empty',
- },
- ],
+ {
+ score: 0.6,
+ state: {},
+ title: 'Suggestion2',
+ previewIcon: 'empty',
},
- }}
- datasourceMap={{
- testDatasource: {
- ...mockDatasource,
- getDatasourceSuggestionsForField: () => [generateSuggestion()],
- getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()],
- getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()],
- renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => {
- if (!dragging || dragging.id !== 'draggedField') {
- setDragging({
- id: 'draggedField',
- humanData: { label: 'draggedField' },
- });
- }
- },
+ ],
+ },
+ testVis2: {
+ ...mockVisualization2,
+ getSuggestions: () => [
+ {
+ score: 0.8,
+ state: suggestionVisState,
+ title: 'Suggestion3',
+ previewIcon: 'empty',
},
- }}
- initialDatasourceId="testDatasource"
- initialVisualizationId="testVis2"
- ExpressionRenderer={expressionRendererMock}
- />
- );
- });
+ ],
+ },
+ },
+ datasourceMap: {
+ testDatasource: {
+ ...mockDatasource,
+ getDatasourceSuggestionsForField: () => [generateSuggestion()],
+ getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()],
+ getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()],
+ renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => {
+ if (!dragging || dragging.id !== 'draggedField') {
+ setDragging({
+ id: 'draggedField',
+ humanData: { label: 'draggedField' },
+ });
+ }
+ },
+ },
+ },
+ ExpressionRenderer: expressionRendererMock,
+ } as EditorFrameProps;
+ instance = (await mountWithProvider(, props.plugins.data)).instance;
// TODO why is this necessary?
instance.update();
@@ -1384,58 +1300,55 @@ describe('editor_frame', () => {
],
};
- await act(async () => {
- instance = mount(
- [
- {
- score: 0.2,
- state: {},
- title: 'Suggestion1',
- previewIcon: 'empty',
- },
- {
- score: 0.6,
- state: {},
- title: 'Suggestion2',
- previewIcon: 'empty',
- },
- ],
- },
- testVis2: {
- ...mockVisualization2,
- getSuggestions: () => [],
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: {
+ ...mockVisualization,
+ getSuggestions: () => [
+ {
+ score: 0.2,
+ state: {},
+ title: 'Suggestion1',
+ previewIcon: 'empty',
},
- testVis3: {
- ...mockVisualization3,
+ {
+ score: 0.6,
+ state: {},
+ title: 'Suggestion2',
+ previewIcon: 'empty',
},
- }}
- datasourceMap={{
- testDatasource: {
- ...mockDatasource,
- getDatasourceSuggestionsForField: () => [generateSuggestion()],
- getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()],
- getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()],
- renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => {
- if (!dragging || dragging.id !== 'draggedField') {
- setDragging({
- id: 'draggedField',
- humanData: { label: '1' },
- });
- }
- },
- },
- }}
- initialDatasourceId="testDatasource"
- initialVisualizationId="testVis2"
- ExpressionRenderer={expressionRendererMock}
- />
- );
- });
+ ],
+ },
+ testVis2: {
+ ...mockVisualization2,
+ getSuggestions: () => [],
+ },
+ testVis3: {
+ ...mockVisualization3,
+ },
+ },
+ datasourceMap: {
+ testDatasource: {
+ ...mockDatasource,
+ getDatasourceSuggestionsForField: () => [generateSuggestion()],
+ getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()],
+ getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()],
+ renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => {
+ if (!dragging || dragging.id !== 'draggedField') {
+ setDragging({
+ id: 'draggedField',
+ humanData: { label: '1' },
+ });
+ }
+ },
+ },
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ } as EditorFrameProps;
+
+ instance = (await mountWithProvider(, props.plugins.data)).instance;
// TODO why is this necessary?
instance.update();
@@ -1481,74 +1394,79 @@ describe('editor_frame', () => {
}));
mockVisualization.initialize.mockReturnValue({ initialState: true });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ onChange,
+ };
+
+ let lensStore: LensRootStore = {} as LensRootStore;
await act(async () => {
- mount(
-
- );
- expect(onChange).toHaveBeenCalledTimes(0);
+ const mounted = await mountWithProvider(, props.plugins.data);
+ lensStore = mounted.lensStore;
+ expect(lensStore.dispatch).toHaveBeenCalledTimes(0);
resolver({});
});
- expect(onChange).toHaveBeenCalledTimes(2);
- expect(onChange).toHaveBeenNthCalledWith(1, {
- filterableIndexPatterns: ['1'],
- doc: {
- id: undefined,
- description: undefined,
- references: [
- {
- id: '1',
- name: 'index-pattern-0',
- type: 'index-pattern',
+ expect(lensStore.dispatch).toHaveBeenCalledTimes(2);
+ expect(lensStore.dispatch).toHaveBeenNthCalledWith(1, {
+ payload: {
+ indexPatternsForTopNav: [{ id: '1' }],
+ lastKnownDoc: {
+ savedObjectId: undefined,
+ description: undefined,
+ references: [
+ {
+ id: '1',
+ name: 'index-pattern-0',
+ type: 'index-pattern',
+ },
+ ],
+ state: {
+ visualization: null, // Not yet loaded
+ datasourceStates: { testDatasource: {} },
+ query: { query: '', language: 'lucene' },
+ filters: [],
},
- ],
- state: {
- visualization: null, // Not yet loaded
- datasourceStates: { testDatasource: {} },
- query: { query: '', language: 'lucene' },
- filters: [],
+ title: '',
+ type: 'lens',
+ visualizationType: 'testVis',
},
- title: '',
- type: 'lens',
- visualizationType: 'testVis',
},
- isSaveable: false,
+ type: 'app/onChangeFromEditorFrame',
});
- expect(onChange).toHaveBeenLastCalledWith({
- filterableIndexPatterns: ['1'],
- doc: {
- references: [
- {
- id: '1',
- name: 'index-pattern-0',
- type: 'index-pattern',
+ expect(lensStore.dispatch).toHaveBeenLastCalledWith({
+ payload: {
+ indexPatternsForTopNav: [{ id: '1' }],
+ lastKnownDoc: {
+ references: [
+ {
+ id: '1',
+ name: 'index-pattern-0',
+ type: 'index-pattern',
+ },
+ ],
+ description: undefined,
+ savedObjectId: undefined,
+ state: {
+ visualization: { initialState: true }, // Now loaded
+ datasourceStates: { testDatasource: {} },
+ query: { query: '', language: 'lucene' },
+ filters: [],
},
- ],
- description: undefined,
- id: undefined,
- state: {
- visualization: { initialState: true }, // Now loaded
- datasourceStates: { testDatasource: {} },
- query: { query: '', language: 'lucene' },
- filters: [],
+ title: '',
+ type: 'lens',
+ visualizationType: 'testVis',
},
- title: '',
- type: 'lens',
- visualizationType: 'testVis',
},
- isSaveable: false,
+ type: 'app/onChangeFromEditorFrame',
});
});
@@ -1561,48 +1479,63 @@ describe('editor_frame', () => {
mockDatasource.getLayers.mockReturnValue(['first']);
mockVisualization.initialize.mockReturnValue({ initialState: true });
- await act(async () => {
- instance = mount(
-
- );
- });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ onChange,
+ };
- expect(onChange).toHaveBeenCalledTimes(2);
+ const { instance: el, lensStore } = await mountWithProvider(
+ ,
+ props.plugins.data
+ );
+ instance = el;
+
+ expect(lensStore.dispatch).toHaveBeenCalledTimes(2);
mockDatasource.toExpression.mockReturnValue('data expression');
mockVisualization.toExpression.mockReturnValue('vis expression');
- instance.setProps({ query: { query: 'new query', language: 'lucene' } });
+ await act(async () => {
+ lensStore.dispatch(setState({ query: { query: 'new query', language: 'lucene' } }));
+ });
+
instance.update();
- expect(onChange).toHaveBeenCalledTimes(3);
- expect(onChange).toHaveBeenNthCalledWith(3, {
- filterableIndexPatterns: [],
- doc: {
- id: undefined,
- references: [],
- state: {
- datasourceStates: { testDatasource: { datasource: '' } },
- visualization: { initialState: true },
- query: { query: 'new query', language: 'lucene' },
- filters: [],
+ expect(lensStore.dispatch).toHaveBeenCalledTimes(4);
+ expect(lensStore.dispatch).toHaveBeenNthCalledWith(3, {
+ payload: {
+ query: {
+ language: 'lucene',
+ query: 'new query',
},
- title: '',
- type: 'lens',
- visualizationType: 'testVis',
},
- isSaveable: true,
+ type: 'app/setState',
+ });
+ expect(lensStore.dispatch).toHaveBeenNthCalledWith(4, {
+ payload: {
+ lastKnownDoc: {
+ savedObjectId: undefined,
+ references: [],
+ state: {
+ datasourceStates: { testDatasource: { datasource: '' } },
+ visualization: { initialState: true },
+ query: { query: 'new query', language: 'lucene' },
+ filters: [],
+ },
+ title: '',
+ type: 'lens',
+ visualizationType: 'testVis',
+ },
+ isSaveable: true,
+ },
+ type: 'app/onChangeFromEditorFrame',
});
});
@@ -1617,21 +1550,23 @@ describe('editor_frame', () => {
}));
mockVisualization.initialize.mockReturnValue({ initialState: true });
- await act(async () => {
- instance = mount(
-
- );
- });
+ const props = {
+ ...getDefaultProps(),
+ visualizationMap: {
+ testVis: mockVisualization,
+ },
+ datasourceMap: {
+ testDatasource: mockDatasource,
+ },
+
+ ExpressionRenderer: expressionRendererMock,
+ onChange,
+ };
+ const mounted = await mountWithProvider(, props.plugins.data);
+ instance = mounted.instance;
+ const { lensStore } = mounted;
- expect(onChange).toHaveBeenCalledTimes(2);
+ expect(lensStore.dispatch).toHaveBeenCalledTimes(2);
await act(async () => {
(instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({
@@ -1643,7 +1578,7 @@ describe('editor_frame', () => {
});
});
- expect(onChange).toHaveBeenCalledTimes(3);
+ expect(lensStore.dispatch).toHaveBeenCalledTimes(3);
});
});
});
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx
index 91b59664ada83..4710e03d336bc 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx
@@ -7,7 +7,10 @@
import React, { useEffect, useReducer, useState, useCallback } 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';
@@ -20,7 +23,6 @@ import { Document } from '../../persistence/saved_object_store';
import { DragDropIdentifier, RootDragDropProvider } from '../../drag_drop';
import { getSavedObjectFormat } from './save';
import { generateId } from '../../id_generator';
-import { Filter, Query, SavedQuery } from '../../../../../../src/plugins/data/public';
import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public';
import { EditorFrameStartPlugins } from '../service';
import { initializeDatasources, createDatasourceLayers } from './state_helpers';
@@ -30,37 +32,45 @@ import {
switchToSuggestion,
} from './suggestion_helpers';
import { trackUiEvent } from '../../lens_ui_telemetry';
+import {
+ useLensSelector,
+ useLensDispatch,
+ LensAppState,
+ DispatchSetState,
+ onChangeFromEditorFrame,
+} from '../../state_management';
export interface EditorFrameProps {
- doc?: Document;
datasourceMap: Record;
visualizationMap: Record;
- initialDatasourceId: string | null;
- initialVisualizationId: string | null;
ExpressionRenderer: ReactExpressionRendererType;
palettes: PaletteRegistry;
onError: (e: { message: string }) => void;
core: CoreStart;
plugins: EditorFrameStartPlugins;
- dateRange: {
- fromDate: string;
- toDate: string;
- };
- query: Query;
- filters: Filter[];
- savedQuery?: SavedQuery;
- searchSessionId: string;
- onChange: (arg: {
- filterableIndexPatterns: string[];
- doc: Document;
- isSaveable: boolean;
- }) => void;
showNoDataPopover: () => void;
initialContext?: VisualizeFieldContext;
}
export function EditorFrame(props: EditorFrameProps) {
- const [state, dispatch] = useReducer(reducer, props, getInitialState);
+ const {
+ filters,
+ searchSessionId,
+ savedQuery,
+ query,
+ persistedDoc,
+ indexPatternsForTopNav,
+ lastKnownDoc,
+ activeData,
+ isSaveable,
+ resolvedDateRange: dateRange,
+ } = useLensSelector((state) => state.app);
+ const [state, dispatch] = useReducer(reducer, { ...props, doc: persistedDoc }, getInitialState);
+ const dispatchLens = useLensDispatch();
+ const dispatchChange: DispatchSetState = useCallback(
+ (s: Partial) => dispatchLens(onChangeFromEditorFrame(s)),
+ [dispatchLens]
+ );
const [visualizeTriggerFieldContext, setVisualizeTriggerFieldContext] = useState(
props.initialContext
);
@@ -81,7 +91,7 @@ export function EditorFrame(props: EditorFrameProps) {
initializeDatasources(
props.datasourceMap,
state.datasourceStates,
- props.doc?.references,
+ persistedDoc?.references,
visualizeTriggerFieldContext,
{ isFullEditor: true }
)
@@ -109,11 +119,11 @@ export function EditorFrame(props: EditorFrameProps) {
const framePublicAPI: FramePublicAPI = {
datasourceLayers,
- activeData: state.activeData,
- dateRange: props.dateRange,
- query: props.query,
- filters: props.filters,
- searchSessionId: props.searchSessionId,
+ activeData,
+ dateRange,
+ query,
+ filters,
+ searchSessionId,
availablePalettes: props.palettes,
addNewLayer() {
@@ -160,19 +170,19 @@ export function EditorFrame(props: EditorFrameProps) {
useEffect(
() => {
- if (props.doc) {
+ if (persistedDoc) {
dispatch({
type: 'VISUALIZATION_LOADED',
doc: {
- ...props.doc,
+ ...persistedDoc,
state: {
- ...props.doc.state,
- visualization: props.doc.visualizationType
- ? props.visualizationMap[props.doc.visualizationType].initialize(
+ ...persistedDoc.state,
+ visualization: persistedDoc.visualizationType
+ ? props.visualizationMap[persistedDoc.visualizationType].initialize(
framePublicAPI,
- props.doc.state.visualization
+ persistedDoc.state.visualization
)
- : props.doc.state.visualization,
+ : persistedDoc.state.visualization,
},
},
});
@@ -184,7 +194,7 @@ export function EditorFrame(props: EditorFrameProps) {
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
- [props.doc]
+ [persistedDoc]
);
// Initialize visualization as soon as all datasources are ready
@@ -205,7 +215,7 @@ export function EditorFrame(props: EditorFrameProps) {
// Get suggestions for visualize field when all datasources are ready
useEffect(() => {
- if (allLoaded && visualizeTriggerFieldContext && !props.doc) {
+ if (allLoaded && visualizeTriggerFieldContext && !persistedDoc) {
applyVisualizeFieldSuggestions({
datasourceMap: props.datasourceMap,
datasourceStates: state.datasourceStates,
@@ -220,6 +230,51 @@ export function EditorFrame(props: EditorFrameProps) {
// 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 | undefined> = async (
+ { filterableIndexPatterns, doc, isSaveable: incomingIsSaveable },
+ prevState
+ ) => {
+ const batchedStateToUpdate: Partial = {};
+
+ 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(
() => {
@@ -232,31 +287,43 @@ export function EditorFrame(props: EditorFrameProps) {
return;
}
- props.onChange(
- getSavedObjectFormat({
- activeDatasources: Object.keys(state.datasourceStates).reduce(
- (datasourceMap, datasourceId) => ({
- ...datasourceMap,
- [datasourceId]: props.datasourceMap[datasourceId],
- }),
- {}
- ),
- visualization: activeVisualization,
- state,
- framePublicAPI,
- })
- );
+ 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,
- state.activeData,
- props.query,
- props.filters,
- props.savedQuery,
+ activeData,
+ query,
+ filters,
+ savedQuery,
state.title,
+ dispatchChange,
]
);
@@ -326,9 +393,9 @@ export function EditorFrame(props: EditorFrameProps) {
}
dispatch={dispatch}
core={props.core}
- query={props.query}
- dateRange={props.dateRange}
- filters={props.filters}
+ query={query}
+ dateRange={dateRange}
+ filters={filters}
showNoDataPopover={props.showNoDataPopover}
dropOntoWorkspace={dropOntoWorkspace}
hasSuggestionForField={hasSuggestionForField}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts
index 6eec13dd9d7ce..86a28be65d2b9 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts
@@ -5,9 +5,8 @@
* 2.0.
*/
-import _ from 'lodash';
+import { uniq } from 'lodash';
import { SavedObjectReference } from 'kibana/public';
-import { Datatable } from 'src/plugins/expressions';
import { EditorFrameState } from './state_management';
import { Document } from '../../persistence/saved_object_store';
import { Datasource, Visualization, FramePublicAPI } from '../../types';
@@ -30,7 +29,6 @@ export function getSavedObjectFormat({
doc: Document;
filterableIndexPatterns: string[];
isSaveable: boolean;
- activeData: Record | undefined;
} {
const datasourceStates: Record = {};
const references: SavedObjectReference[] = [];
@@ -42,7 +40,7 @@ export function getSavedObjectFormat({
references.push(...savedObjectReferences);
});
- const uniqueFilterableIndexPatternIds = _.uniq(
+ const uniqueFilterableIndexPatternIds = uniq(
references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id)
);
@@ -77,6 +75,5 @@ export function getSavedObjectFormat({
},
filterableIndexPatterns: uniqueFilterableIndexPatternIds,
isSaveable: expression !== null,
- activeData: state.activeData,
};
}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts
index 5d6dae557dbb8..af8a9c0a85558 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts
@@ -24,10 +24,7 @@ describe('editor_frame state management', () => {
onError: jest.fn(),
datasourceMap: { testDatasource: ({} as unknown) as Datasource },
visualizationMap: { testVis: ({ initialize: jest.fn() } as unknown) as Visualization },
- initialDatasourceId: 'testDatasource',
- initialVisualizationId: 'testVis',
ExpressionRenderer: createExpressionRendererMock(),
- onChange: jest.fn(),
core: coreMock.createStart(),
plugins: {
uiActions: uiActionsPluginMock.createStartContract(),
@@ -36,11 +33,7 @@ describe('editor_frame state management', () => {
charts: chartPluginMock.createStartContract(),
},
palettes: chartPluginMock.createPaletteRegistry(),
- dateRange: { fromDate: 'now-7d', toDate: 'now' },
- query: { query: '', language: 'lucene' },
- filters: [],
showNoDataPopover: jest.fn(),
- searchSessionId: 'sessionId',
};
});
@@ -101,8 +94,8 @@ describe('editor_frame state management', () => {
`);
});
- it('should not set active id if no initial visualization is passed in', () => {
- const initialState = getInitialState({ ...props, initialVisualizationId: 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);
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts
index 53aba0d6f3f6c..aa365d1e66d6c 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts
@@ -7,7 +7,6 @@
import { EditorFrameProps } from './index';
import { Document } from '../../persistence/saved_object_store';
-import { TableInspectorAdapter } from '../types';
export interface PreviewState {
visualization: {
@@ -23,7 +22,6 @@ export interface EditorFrameState extends PreviewState {
description?: string;
stagedPreview?: PreviewState;
activeDatasourceId: string | null;
- activeData?: TableInspectorAdapter;
}
export type Action =
@@ -35,10 +33,6 @@ export type Action =
type: 'UPDATE_TITLE';
title: string;
}
- | {
- type: 'UPDATE_ACTIVE_DATA';
- tables: TableInspectorAdapter;
- }
| {
type: 'UPDATE_STATE';
// Just for diagnostics, so we can determine what action
@@ -103,25 +97,27 @@ export function getActiveDatasourceIdFromDoc(doc?: Document) {
return null;
}
- const [initialDatasourceId] = Object.keys(doc.state.datasourceStates);
- return initialDatasourceId || null;
+ const [firstDatasourceFromDoc] = Object.keys(doc.state.datasourceStates);
+ return firstDatasourceFromDoc || null;
}
-function getInitialDatasourceId(props: EditorFrameProps) {
- return props.initialDatasourceId
- ? props.initialDatasourceId
- : getActiveDatasourceIdFromDoc(props.doc);
-}
-
-export const getInitialState = (props: EditorFrameProps): EditorFrameState => {
+export const getInitialState = (
+ params: EditorFrameProps & { doc?: Document }
+): EditorFrameState => {
const datasourceStates: EditorFrameState['datasourceStates'] = {};
- if (props.doc) {
- Object.entries(props.doc.state.datasourceStates).forEach(([datasourceId, state]) => {
+ 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 (props.initialDatasourceId) {
- datasourceStates[props.initialDatasourceId] = {
+ } else if (initialDatasourceId) {
+ datasourceStates[initialDatasourceId] = {
state: null,
isLoading: true,
};
@@ -130,10 +126,10 @@ export const getInitialState = (props: EditorFrameProps): EditorFrameState => {
return {
title: '',
datasourceStates,
- activeDatasourceId: getInitialDatasourceId(props),
+ activeDatasourceId: initialDatasourceId,
visualization: {
state: null,
- activeId: props.initialVisualizationId,
+ activeId: initialVisualizationId,
},
};
};
@@ -146,11 +142,6 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta
return { ...state, title: action.title };
case 'UPDATE_STATE':
return action.updater(state);
- case 'UPDATE_ACTIVE_DATA':
- return {
- ...state,
- activeData: { ...action.tables },
- };
case 'UPDATE_LAYER':
return {
...state,
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts
index 83b0922626542..bd8f134f59fbb 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import _ from 'lodash';
+import { flatten } from 'lodash';
import { Ast } from '@kbn/interpreter/common';
import { IconType } from '@elastic/eui/src/components/icon/icon';
import { Datatable } from 'src/plugins/expressions';
@@ -79,7 +79,7 @@ export function getSuggestions({
);
// Collect all table suggestions from available datasources
- const datasourceTableSuggestions = _.flatten(
+ const datasourceTableSuggestions = flatten(
datasources.map(([datasourceId, datasource]) => {
const datasourceState = datasourceStates[datasourceId].state;
let dataSourceSuggestions;
@@ -103,9 +103,9 @@ export function getSuggestions({
// Pass all table suggestions to all visualization extensions to get visualization suggestions
// and rank them by score
- return _.flatten(
+ return flatten(
Object.entries(visualizationMap).map(([visualizationId, visualization]) =>
- _.flatten(
+ flatten(
datasourceTableSuggestions.map((datasourceSuggestion) => {
const table = datasourceSuggestion.table;
const currentVisualizationState =
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx
index baa9d45a431ea..1d248c4411023 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx
@@ -16,14 +16,14 @@ import {
DatasourceMock,
createMockFramePublicAPI,
} from '../../mocks';
-
+import { mockDataPlugin, mountWithProvider } from '../../../mocks';
jest.mock('../../../debounced_component', () => {
return {
debouncedComponent: (fn: unknown) => fn,
};
});
-import { WorkspacePanel, WorkspacePanelProps } from './workspace_panel';
+import { WorkspacePanel } from './workspace_panel';
import { mountWithIntl as mount } from '@kbn/test/jest';
import { ReactWrapper } from 'enzyme';
import { DragDrop, ChildDragDropProvider } from '../../../drag_drop';
@@ -34,7 +34,6 @@ import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/publ
import { uiActionsPluginMock } from '../../../../../../../src/plugins/ui_actions/public/mocks';
import { TriggerContract } from '../../../../../../../src/plugins/ui_actions/public/triggers';
import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public/embeddable';
-import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
const defaultPermissions: Record>> = {
navLinks: { management: true },
@@ -50,24 +49,22 @@ function createCoreStartWithPermissions(newCapabilities = defaultPermissions) {
return core;
}
-function getDefaultProps() {
- return {
- activeDatasourceId: 'mock',
- datasourceStates: {},
- datasourceMap: {},
- framePublicAPI: createMockFramePublicAPI(),
- activeVisualizationId: 'vis',
- visualizationState: {},
- dispatch: () => {},
- ExpressionRenderer: createExpressionRendererMock(),
- core: createCoreStartWithPermissions(),
- plugins: {
- uiActions: uiActionsPluginMock.createStartContract(),
- data: dataPluginMock.createStartContract(),
- },
- getSuggestionForField: () => undefined,
- };
-}
+const defaultProps = {
+ activeDatasourceId: 'mock',
+ datasourceStates: {},
+ datasourceMap: {},
+ framePublicAPI: createMockFramePublicAPI(),
+ activeVisualizationId: 'vis',
+ visualizationState: {},
+ dispatch: () => {},
+ ExpressionRenderer: createExpressionRendererMock(),
+ core: createCoreStartWithPermissions(),
+ plugins: {
+ uiActions: uiActionsPluginMock.createStartContract(),
+ data: mockDataPlugin(),
+ },
+ getSuggestionForField: () => undefined,
+};
describe('workspace_panel', () => {
let mockVisualization: jest.Mocked;
@@ -78,7 +75,7 @@ describe('workspace_panel', () => {
let uiActionsMock: jest.Mocked;
let trigger: jest.Mocked;
- let instance: ReactWrapper;
+ let instance: ReactWrapper;
beforeEach(() => {
// These are used in specific tests to assert function calls
@@ -95,50 +92,56 @@ describe('workspace_panel', () => {
instance.unmount();
});
- it('should render an explanatory text if no visualization is active', () => {
- instance = mount(
+ it('should render an explanatory text if no visualization is active', async () => {
+ const mounted = await mountWithProvider(
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2);
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
- it('should render an explanatory text if the visualization does not produce an expression', () => {
- instance = mount(
+ it('should render an explanatory text if the visualization does not produce an expression', async () => {
+ const mounted = await mountWithProvider(
null },
}}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2);
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
- it('should render an explanatory text if the datasource does not produce an expression', () => {
- instance = mount(
+ it('should render an explanatory text if the datasource does not produce an expression', async () => {
+ const mounted = await mountWithProvider(
'vis' },
}}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2);
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
- it('should render the resulting expression using the expression renderer', () => {
+ it('should render the resulting expression using the expression renderer', async () => {
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
@@ -146,9 +149,9 @@ describe('workspace_panel', () => {
mockDatasource.toExpression.mockReturnValue('datasource');
mockDatasource.getLayers.mockReturnValue(['first']);
- instance = mount(
+ const mounted = await mountWithProvider(
{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
+
expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(`
"kibana
| lens_merge_tables layerIds=\\"first\\" tables={datasource}
@@ -173,16 +179,16 @@ describe('workspace_panel', () => {
`);
});
- it('should execute a trigger on expression event', () => {
+ it('should execute a trigger on expression event', async () => {
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
mockDatasource.toExpression.mockReturnValue('datasource');
mockDatasource.getLayers.mockReturnValue(['first']);
- const props = getDefaultProps();
+ const props = defaultProps;
- instance = mount(
+ const mounted = await mountWithProvider(
{
}}
ExpressionRenderer={expressionRendererMock}
plugins={{ ...props.plugins, uiActions: uiActionsMock }}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
const onEvent = expressionRendererMock.mock.calls[0][0].onEvent!;
@@ -212,7 +220,7 @@ describe('workspace_panel', () => {
expect(trigger.exec).toHaveBeenCalledWith({ data: eventData });
});
- it('should push add current data table to state on data$ emitting value', () => {
+ it('should push add current data table to state on data$ emitting value', async () => {
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
@@ -221,9 +229,9 @@ describe('workspace_panel', () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const dispatch = jest.fn();
- instance = mount(
+ const mounted = await mountWithProvider(
{
}}
dispatch={dispatch}
ExpressionRenderer={expressionRendererMock}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
+
const onData = expressionRendererMock.mock.calls[0][0].onData$!;
const tableData = { table1: { columns: [], rows: [] } };
onData(undefined, { tables: { tables: tableData } });
- expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_ACTIVE_DATA', tables: tableData });
+ expect(mounted.lensStore.dispatch).toHaveBeenCalledWith({
+ type: 'app/onActiveDataChange',
+ payload: { activeData: tableData },
+ });
});
- it('should include data fetching for each layer in the expression', () => {
+ it('should include data fetching for each layer in the expression', async () => {
const mockDatasource2 = createMockDatasource('a');
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
@@ -263,9 +277,9 @@ describe('workspace_panel', () => {
mockDatasource2.toExpression.mockReturnValue('datasource2');
mockDatasource2.getLayers.mockReturnValue(['second', 'third']);
- instance = mount(
+ const mounted = await mountWithProvider(
{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
const ast = fromExpression(instance.find(expressionRendererMock).prop('expression') as string);
@@ -341,9 +357,9 @@ describe('workspace_panel', () => {
expressionRendererMock = jest.fn((_arg) => );
await act(async () => {
- instance = mount(
+ const mounted = await mountWithProvider(
{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
});
instance.update();
@@ -392,9 +410,9 @@ describe('workspace_panel', () => {
expressionRendererMock = jest.fn((_arg) => );
await act(async () => {
- instance = mount(
+ const mounted = await mountWithProvider(
{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
});
instance.update();
@@ -434,16 +454,16 @@ describe('workspace_panel', () => {
expect(expressionRendererMock).toHaveBeenCalledTimes(2);
});
- it('should show an error message if there are missing indexpatterns in the visualization', () => {
+ it('should show an error message if there are missing indexpatterns in the visualization', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
mockDatasource.checkIntegrity.mockReturnValue(['a']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
- instance = mount(
+ const mounted = await mountWithProvider(
{
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
expect(instance.find('[data-test-subj="missing-refs-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
- it('should not show the management action in case of missing indexpattern and no navigation permissions', () => {
+ it('should not show the management action in case of missing indexpattern and no navigation permissions', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
- instance = mount(
+ const mounted = await mountWithProvider(
{
navLinks: { management: false },
management: { kibana: { indexPatterns: true } },
})}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
expect(
instance.find('[data-test-subj="configuration-failure-reconfigure-indexpatterns"]').exists()
).toBeFalsy();
});
- it('should not show the management action in case of missing indexpattern and no indexPattern specific permissions', () => {
+ it('should not show the management action in case of missing indexpattern and no indexPattern specific permissions', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
- instance = mount(
+ const mounted = await mountWithProvider(
{
navLinks: { management: true },
management: { kibana: { indexPatterns: false } },
})}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
expect(
instance.find('[data-test-subj="configuration-failure-reconfigure-indexpatterns"]').exists()
).toBeFalsy();
});
- it('should show an error message if validation on datasource does not pass', () => {
+ it('should show an error message if validation on datasource does not pass', async () => {
mockDatasource.getErrorMessages.mockReturnValue([
{ shortMessage: 'An error occurred', longMessage: 'An long description here' },
]);
@@ -550,9 +576,9 @@ describe('workspace_panel', () => {
first: mockDatasource.publicAPIMock,
};
- instance = mount(
+ const mounted = await mountWithProvider(
{
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
expect(instance.find('[data-test-subj="configuration-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
- it('should show an error message if validation on visualization does not pass', () => {
+ it('should show an error message if validation on visualization does not pass', async () => {
mockDatasource.getErrorMessages.mockReturnValue(undefined);
mockDatasource.getLayers.mockReturnValue(['first']);
mockVisualization.getErrorMessages.mockReturnValue([
@@ -585,9 +613,9 @@ describe('workspace_panel', () => {
first: mockDatasource.publicAPIMock,
};
- instance = mount(
+ const mounted = await mountWithProvider(
{
visualizationMap={{
vis: mockVisualization,
}}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
expect(instance.find('[data-test-subj="configuration-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
- it('should show an error message if validation on both datasource and visualization do not pass', () => {
+ it('should show an error message if validation on both datasource and visualization do not pass', async () => {
mockDatasource.getErrorMessages.mockReturnValue([
{ shortMessage: 'An error occurred', longMessage: 'An long description here' },
]);
@@ -622,9 +652,9 @@ describe('workspace_panel', () => {
first: mockDatasource.publicAPIMock,
};
- instance = mount(
+ const mounted = await mountWithProvider(
{
visualizationMap={{
vis: mockVisualization,
}}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
// EuiFlexItem duplicates internally the attribute, so we need to filter only the most inner one here
expect(
@@ -648,7 +680,7 @@ describe('workspace_panel', () => {
expect(instance.find(expressionRendererMock)).toHaveLength(0);
});
- it('should show an error message if the expression fails to parse', () => {
+ it('should show an error message if the expression fails to parse', async () => {
mockDatasource.toExpression.mockReturnValue('|||');
mockDatasource.getLayers.mockReturnValue(['first']);
const framePublicAPI = createMockFramePublicAPI();
@@ -656,9 +688,9 @@ describe('workspace_panel', () => {
first: mockDatasource.publicAPIMock,
};
- instance = mount(
+ const mounted = await mountWithProvider(
{
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
expect(instance.find('[data-test-subj="expression-failure"]').exists()).toBeTruthy();
expect(instance.find(expressionRendererMock)).toHaveLength(0);
@@ -688,9 +722,9 @@ describe('workspace_panel', () => {
};
await act(async () => {
- instance = mount(
+ const mounted = await mountWithProvider(
{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
});
instance.update();
@@ -727,9 +763,9 @@ describe('workspace_panel', () => {
};
await act(async () => {
- instance = mount(
+ const mounted = await mountWithProvider(
{
vis: { ...mockVisualization, toExpression: () => 'vis' },
}}
ExpressionRenderer={expressionRendererMock}
- />
+ />,
+ defaultProps.plugins.data
);
+ instance = mounted.instance;
});
instance.update();
@@ -791,7 +829,7 @@ describe('workspace_panel', () => {
dropTargetsByOrder={undefined}
>
{
);
}
- it('should immediately transition if exactly one suggestion is returned', () => {
+ it('should immediately transition if exactly one suggestion is returned', async () => {
mockGetSuggestionForField.mockReturnValue({
visualizationId: 'vis',
datasourceState: {},
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx
index 3d5d9a6d84d81..94065f316340c 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx
@@ -54,6 +54,7 @@ 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';
export interface WorkspacePanelProps {
activeVisualizationId: string | null;
@@ -428,16 +429,15 @@ export const VisualizationWrapper = ({
]
);
+ const dispatchLens = useLensDispatch();
+
const onData$ = useCallback(
(data: unknown, inspectorAdapters?: Partial) => {
if (inspectorAdapters && inspectorAdapters.tables) {
- dispatch({
- type: 'UPDATE_ACTIVE_DATA',
- tables: inspectorAdapters.tables.tables,
- });
+ dispatchLens(onActiveDataChange({ activeData: { ...inspectorAdapters.tables.tables } }));
}
},
- [dispatch]
+ [dispatchLens]
);
if (localState.configurationValidationError?.length) {
diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx
index f6500596ce5a0..62274df23e837 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx
@@ -126,26 +126,11 @@ export class EditorFrameService {
collectAsyncDefinitions(this.visualizations),
]);
- const firstDatasourceId = Object.keys(resolvedDatasources)[0];
- const firstVisualizationId = Object.keys(resolvedVisualizations)[0];
-
- const { EditorFrame, getActiveDatasourceIdFromDoc } = await import('../async_services');
-
+ const { EditorFrame } = await import('../async_services');
const palettes = await plugins.charts.palettes.getPalettes();
return {
- EditorFrameContainer: ({
- doc,
- onError,
- dateRange,
- query,
- filters,
- savedQuery,
- onChange,
- showNoDataPopover,
- initialContext,
- searchSessionId,
- }) => {
+ EditorFrameContainer: ({ onError, showNoDataPopover, initialContext }) => {
return (
);
diff --git a/x-pack/plugins/lens/public/mocks.tsx b/x-pack/plugins/lens/public/mocks.tsx
index c1f885d167659..473c170aef294 100644
--- a/x-pack/plugins/lens/public/mocks.tsx
+++ b/x-pack/plugins/lens/public/mocks.tsx
@@ -6,8 +6,35 @@
*/
import React from 'react';
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { ReactWrapper } from 'enzyme';
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { mountWithIntl as mount } from '@kbn/test/jest';
+import { Observable, Subject } from 'rxjs';
+import { coreMock } from 'src/core/public/mocks';
+import moment from 'moment';
+import { Provider } from 'react-redux';
+import { act } from 'react-dom/test-utils';
import { LensPublicStart } from '.';
import { visualizationTypes } from './xy_visualization/types';
+import { navigationPluginMock } from '../../../../src/plugins/navigation/public/mocks';
+import { LensAppServices } from './app_plugin/types';
+import { DOC_TYPE } from '../common';
+import { DataPublicPluginStart, esFilters, UI_SETTINGS } from '../../../../src/plugins/data/public';
+import {
+ LensByValueInput,
+ LensSavedObjectAttributes,
+ LensByReferenceInput,
+} from './editor_frame_service/embeddable/embeddable';
+import {
+ mockAttributeService,
+ createEmbeddableStateTransferMock,
+} from '../../../../src/plugins/embeddable/public/mocks';
+import { LensAttributeService } from './lens_attribute_service';
+import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/public';
+
+import { makeConfigureStore, getPreloadedState, LensAppState } from './state_management/index';
+import { getResolvedDateRange } from './utils';
export type Start = jest.Mocked;
@@ -26,3 +53,252 @@ const createStartContract = (): Start => {
export const lensPluginMock = {
createStartContract,
};
+
+export const defaultDoc = ({
+ savedObjectId: '1234',
+ title: 'An extremely cool default document!',
+ expression: 'definitely a valid expression',
+ state: {
+ query: 'kuery',
+ filters: [{ query: { match_phrase: { src: 'test' } } }],
+ },
+ references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }],
+} as unknown) as Document;
+
+export function createMockTimefilter() {
+ const unsubscribe = jest.fn();
+
+ let timeFilter = { from: 'now-7d', to: 'now' };
+ let subscriber: () => void;
+ return {
+ getTime: jest.fn(() => timeFilter),
+ setTime: jest.fn((newTimeFilter) => {
+ timeFilter = newTimeFilter;
+ if (subscriber) {
+ subscriber();
+ }
+ }),
+ getTimeUpdate$: () => ({
+ subscribe: ({ next }: { next: () => void }) => {
+ subscriber = next;
+ return unsubscribe;
+ },
+ }),
+ calculateBounds: jest.fn(() => ({
+ min: moment('2021-01-10T04:00:00.000Z'),
+ max: moment('2021-01-10T08:00:00.000Z'),
+ })),
+ getBounds: jest.fn(() => timeFilter),
+ getRefreshInterval: () => {},
+ getRefreshIntervalDefaults: () => {},
+ getAutoRefreshFetch$: () => new Observable(),
+ };
+}
+
+export function mockDataPlugin(sessionIdSubject = new Subject()) {
+ function createMockSearchService() {
+ let sessionIdCounter = 1;
+ return {
+ session: {
+ start: jest.fn(() => `sessionId-${sessionIdCounter++}`),
+ clear: jest.fn(),
+ getSessionId: jest.fn(() => `sessionId-${sessionIdCounter}`),
+ getSession$: jest.fn(() => sessionIdSubject.asObservable()),
+ },
+ };
+ }
+
+ function createMockFilterManager() {
+ const unsubscribe = jest.fn();
+
+ let subscriber: () => void;
+ let filters: unknown = [];
+
+ return {
+ getUpdates$: () => ({
+ subscribe: ({ next }: { next: () => void }) => {
+ subscriber = next;
+ return unsubscribe;
+ },
+ }),
+ setFilters: jest.fn((newFilters: unknown[]) => {
+ filters = newFilters;
+ if (subscriber) subscriber();
+ }),
+ setAppFilters: jest.fn((newFilters: unknown[]) => {
+ filters = newFilters;
+ if (subscriber) subscriber();
+ }),
+ getFilters: () => filters,
+ getGlobalFilters: () => {
+ // @ts-ignore
+ return filters.filter(esFilters.isFilterPinned);
+ },
+ removeAll: () => {
+ filters = [];
+ subscriber();
+ },
+ };
+ }
+
+ function createMockQueryString() {
+ return {
+ getQuery: jest.fn(() => ({ query: '', language: 'lucene' })),
+ setQuery: jest.fn(),
+ getDefaultQuery: jest.fn(() => ({ query: '', language: 'lucene' })),
+ };
+ }
+ return ({
+ query: {
+ filterManager: createMockFilterManager(),
+ timefilter: {
+ timefilter: createMockTimefilter(),
+ },
+ queryString: createMockQueryString(),
+ state$: new Observable(),
+ },
+ indexPatterns: {
+ get: jest.fn((id) => {
+ return new Promise((resolve) => resolve({ id }));
+ }),
+ },
+ search: createMockSearchService(),
+ nowProvider: {
+ get: jest.fn(),
+ },
+ } as unknown) as DataPublicPluginStart;
+}
+
+export function makeDefaultServices(
+ sessionIdSubject = new Subject(),
+ doc = defaultDoc
+): jest.Mocked {
+ const core = coreMock.createStart({ basePath: '/testbasepath' });
+ core.uiSettings.get.mockImplementation(
+ jest.fn((type) => {
+ if (type === UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS) {
+ return { from: 'now-7d', to: 'now' };
+ } else if (type === UI_SETTINGS.SEARCH_QUERY_LANGUAGE) {
+ return 'kuery';
+ } else if (type === 'state:storeInSessionStorage') {
+ return false;
+ } else {
+ return [];
+ }
+ })
+ );
+
+ const navigationStartMock = navigationPluginMock.createStartContract();
+
+ jest.spyOn(navigationStartMock.ui.TopNavMenu.prototype, 'constructor').mockImplementation(() => {
+ return ;
+ });
+
+ function makeAttributeService(): LensAttributeService {
+ const attributeServiceMock = mockAttributeService<
+ LensSavedObjectAttributes,
+ LensByValueInput,
+ LensByReferenceInput
+ >(
+ DOC_TYPE,
+ {
+ saveMethod: jest.fn(),
+ unwrapMethod: jest.fn(),
+ checkForDuplicateTitle: jest.fn(),
+ },
+ core
+ );
+
+ attributeServiceMock.unwrapAttributes = jest.fn().mockResolvedValue(doc);
+ attributeServiceMock.wrapAttributes = jest.fn().mockResolvedValue({
+ savedObjectId: ((doc as unknown) as LensByReferenceInput).savedObjectId,
+ });
+
+ return attributeServiceMock;
+ }
+
+ return {
+ http: core.http,
+ chrome: core.chrome,
+ overlays: core.overlays,
+ uiSettings: core.uiSettings,
+ navigation: navigationStartMock,
+ notifications: core.notifications,
+ attributeService: makeAttributeService(),
+ savedObjectsClient: core.savedObjects.client,
+ dashboardFeatureFlag: { allowByValueEmbeddables: false },
+ stateTransfer: createEmbeddableStateTransferMock() as EmbeddableStateTransfer,
+ getOriginatingAppName: jest.fn(() => 'defaultOriginatingApp'),
+ application: {
+ ...core.application,
+ capabilities: {
+ ...core.application.capabilities,
+ visualize: { save: true, saveQuery: true, show: true },
+ },
+ getUrlForApp: jest.fn((appId: string) => `/testbasepath/app/${appId}#/`),
+ },
+ data: mockDataPlugin(sessionIdSubject),
+ storage: {
+ get: jest.fn(),
+ set: jest.fn(),
+ remove: jest.fn(),
+ clear: jest.fn(),
+ },
+ };
+}
+
+export function mockLensStore({
+ data,
+ storePreloadedState,
+}: {
+ data: DataPublicPluginStart;
+ storePreloadedState?: Partial;
+}) {
+ const lensStore = makeConfigureStore(
+ getPreloadedState({
+ query: data.query.queryString.getQuery(),
+ filters: data.query.filterManager.getGlobalFilters(),
+ searchSessionId: data.search.session.start(),
+ resolvedDateRange: getResolvedDateRange(data.query.timefilter.timefilter),
+ ...storePreloadedState,
+ }),
+ {
+ data,
+ }
+ );
+
+ const origDispatch = lensStore.dispatch;
+ lensStore.dispatch = jest.fn(origDispatch);
+ return lensStore;
+}
+
+export const mountWithProvider = async (
+ component: React.ReactElement,
+ data: DataPublicPluginStart,
+ storePreloadedState?: Partial,
+ extraWrappingComponent?: React.FC<{
+ children: React.ReactNode;
+ }>
+) => {
+ const lensStore = mockLensStore({ data, storePreloadedState });
+
+ const wrappingComponent: React.FC<{
+ children: React.ReactNode;
+ }> = ({ children }) => {
+ if (extraWrappingComponent) {
+ return extraWrappingComponent({
+ children: {children},
+ });
+ }
+ return {children};
+ };
+
+ let instance: ReactWrapper = {} as ReactWrapper;
+
+ await act(async () => {
+ instance = mount(component, ({
+ wrappingComponent,
+ } as unknown) as ReactWrapper);
+ });
+ return { instance, lensStore };
+};
diff --git a/x-pack/plugins/lens/public/shared_components/debounced_value.ts b/x-pack/plugins/lens/public/shared_components/debounced_value.ts
index 5447384ce38ea..1f8ba0fa765b2 100644
--- a/x-pack/plugins/lens/public/shared_components/debounced_value.ts
+++ b/x-pack/plugins/lens/public/shared_components/debounced_value.ts
@@ -6,7 +6,7 @@
*/
import { useState, useMemo, useEffect, useRef } from 'react';
-import _ from 'lodash';
+import { debounce } from 'lodash';
/**
* Debounces value changes and updates inputValue on root state changes if no debounced changes
@@ -27,7 +27,7 @@ export const useDebouncedValue = ({
const initialValue = useRef(value);
const onChangeDebounced = useMemo(() => {
- const callback = _.debounce((val: T) => {
+ const callback = debounce((val: T) => {
onChange(val);
unflushedChanges.current = false;
}, 256);
diff --git a/x-pack/plugins/lens/public/state_management/app_slice.ts b/x-pack/plugins/lens/public/state_management/app_slice.ts
new file mode 100644
index 0000000000000..29d5b0bee843f
--- /dev/null
+++ b/x-pack/plugins/lens/public/state_management/app_slice.ts
@@ -0,0 +1,55 @@
+/*
+ * 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>) => {
+ return {
+ ...state,
+ ...payload,
+ };
+ },
+ onChangeFromEditorFrame: (state, { payload }: PayloadAction>) => {
+ return {
+ ...state,
+ ...payload,
+ };
+ },
+ onActiveDataChange: (state, { payload }: PayloadAction>) => {
+ if (!isEqual(state.activeData, payload?.activeData)) {
+ return {
+ ...state,
+ ...payload,
+ };
+ }
+ return state;
+ },
+ navigateAway: (state) => state,
+ },
+});
+
+export const reducer = {
+ app: appSlice.reducer,
+};
diff --git a/x-pack/plugins/lens/public/state_management/external_context_middleware.ts b/x-pack/plugins/lens/public/state_management/external_context_middleware.ts
new file mode 100644
index 0000000000000..35d0f7cf197ed
--- /dev/null
+++ b/x-pack/plugins/lens/public/state_management/external_context_middleware.ts
@@ -0,0 +1,103 @@
+/*
+ * 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 { delay, finalize, switchMap, tap } from 'rxjs/operators';
+import _, { debounce } from 'lodash';
+import { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit';
+import { trackUiEvent } from '../lens_ui_telemetry';
+
+import {
+ waitUntilNextSessionCompletes$,
+ DataPublicPluginStart,
+} from '../../../../../src/plugins/data/public';
+import { setState, LensGetState, LensDispatch } from '.';
+import { LensAppState } from './types';
+import { getResolvedDateRange } from '../utils';
+
+export const externalContextMiddleware = (data: DataPublicPluginStart) => (
+ store: MiddlewareAPI
+) => {
+ const unsubscribeFromExternalContext = subscribeToExternalContext(
+ data,
+ store.getState,
+ store.dispatch
+ );
+ return (next: Dispatch) => (action: PayloadAction>) => {
+ if (action.type === 'app/navigateAway') {
+ unsubscribeFromExternalContext();
+ }
+ next(action);
+ };
+};
+
+function subscribeToExternalContext(
+ data: DataPublicPluginStart,
+ getState: LensGetState,
+ dispatch: LensDispatch
+) {
+ const { query: queryService, search } = data;
+ const { filterManager } = queryService;
+
+ const dispatchFromExternal = (searchSessionId = search.session.start()) => {
+ const globalFilters = filterManager.getFilters();
+ const filters = _.isEqual(getState().app.filters, globalFilters)
+ ? null
+ : { filters: globalFilters };
+ dispatch(
+ setState({
+ searchSessionId,
+ ...filters,
+ resolvedDateRange: getResolvedDateRange(queryService.timefilter.timefilter),
+ })
+ );
+ };
+
+ const debounceDispatchFromExternal = debounce(dispatchFromExternal, 100);
+
+ const sessionSubscription = search.session
+ .getSession$()
+ // wait for a tick to filter/timerange subscribers the chance to update the session id in the state
+ .pipe(delay(0))
+ // then update if it didn't get updated yet
+ .subscribe((newSessionId?: string) => {
+ if (newSessionId && getState().app.searchSessionId !== newSessionId) {
+ debounceDispatchFromExternal(newSessionId);
+ }
+ });
+
+ const filterSubscription = filterManager.getUpdates$().subscribe({
+ next: () => {
+ debounceDispatchFromExternal();
+ trackUiEvent('app_filters_updated');
+ },
+ });
+
+ const timeSubscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({
+ next: () => {
+ debounceDispatchFromExternal();
+ },
+ });
+
+ const autoRefreshSubscription = data.query.timefilter.timefilter
+ .getAutoRefreshFetch$()
+ .pipe(
+ tap(() => {
+ debounceDispatchFromExternal();
+ }),
+ switchMap((done) =>
+ // best way in lens to estimate that all panels are updated is to rely on search session service state
+ waitUntilNextSessionCompletes$(search.session).pipe(finalize(done))
+ )
+ )
+ .subscribe();
+ return () => {
+ filterSubscription.unsubscribe();
+ timeSubscription.unsubscribe();
+ autoRefreshSubscription.unsubscribe();
+ sessionSubscription.unsubscribe();
+ };
+}
diff --git a/x-pack/plugins/lens/public/state_management/index.ts b/x-pack/plugins/lens/public/state_management/index.ts
new file mode 100644
index 0000000000000..429978e60756b
--- /dev/null
+++ b/x-pack/plugins/lens/public/state_management/index.ts
@@ -0,0 +1,76 @@
+/*
+ * 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 { 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 { timeRangeMiddleware } from './time_range_middleware';
+import { externalContextMiddleware } from './external_context_middleware';
+
+import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
+import { LensAppState, LensState } from './types';
+export * from './types';
+
+export const reducer = {
+ app: appSlice.reducer,
+};
+
+export const {
+ setState,
+ navigateAway,
+ onChangeFromEditorFrame,
+ onActiveDataChange,
+} = appSlice.actions;
+
+export const getPreloadedState = (initializedState: Partial) => {
+ const state = {
+ app: {
+ ...initialState,
+ ...initializedState,
+ },
+ } as DeepPartial;
+ return state;
+};
+
+type PreloadedState = ReturnType;
+
+export const makeConfigureStore = (
+ preloadedState: PreloadedState,
+ { data }: { data: DataPublicPluginStart }
+) => {
+ const middleware = [
+ ...getDefaultMiddleware({
+ serializableCheck: {
+ ignoredActions: [
+ 'app/setState',
+ 'app/onChangeFromEditorFrame',
+ 'app/onActiveDataChange',
+ 'app/navigateAway',
+ ],
+ },
+ }),
+ timeRangeMiddleware(data),
+ externalContextMiddleware(data),
+ ];
+ if (process.env.NODE_ENV === 'development') middleware.push(logger);
+
+ return configureStore({
+ reducer,
+ middleware,
+ preloadedState,
+ });
+};
+
+export type LensRootStore = ReturnType;
+
+export type LensDispatch = LensRootStore['dispatch'];
+export type LensGetState = LensRootStore['getState'];
+export type LensRootState = ReturnType;
+
+export const useLensDispatch = () => useDispatch();
+export const useLensSelector: TypedUseSelectorHook = useSelector;
diff --git a/x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts b/x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts
new file mode 100644
index 0000000000000..4145f8ed5e52c
--- /dev/null
+++ b/x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts
@@ -0,0 +1,198 @@
+/*
+ * 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.
+ */
+
+// /*
+// * 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 { 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 { LensAppState } from './types';
+import { PayloadAction } from '@reduxjs/toolkit';
+import { Document } from '../persistence';
+
+const sessionIdSubject = new Subject();
+
+function createMockSearchService() {
+ let sessionIdCounter = 1;
+ return {
+ session: {
+ start: jest.fn(() => `sessionId-${sessionIdCounter++}`),
+ clear: jest.fn(),
+ getSessionId: jest.fn(() => `sessionId-${sessionIdCounter}`),
+ getSession$: jest.fn(() => sessionIdSubject.asObservable()),
+ },
+ };
+}
+
+function createMockFilterManager() {
+ const unsubscribe = jest.fn();
+
+ let subscriber: () => void;
+ let filters: unknown = [];
+
+ return {
+ getUpdates$: () => ({
+ subscribe: ({ next }: { next: () => void }) => {
+ subscriber = next;
+ return unsubscribe;
+ },
+ }),
+ setFilters: jest.fn((newFilters: unknown[]) => {
+ filters = newFilters;
+ if (subscriber) subscriber();
+ }),
+ setAppFilters: jest.fn((newFilters: unknown[]) => {
+ filters = newFilters;
+ if (subscriber) subscriber();
+ }),
+ getFilters: () => filters,
+ getGlobalFilters: () => {
+ // @ts-ignore
+ return filters.filter(esFilters.isFilterPinned);
+ },
+ removeAll: () => {
+ filters = [];
+ subscriber();
+ },
+ };
+}
+
+function createMockQueryString() {
+ return {
+ getQuery: jest.fn(() => ({ query: '', language: 'kuery' })),
+ setQuery: jest.fn(),
+ getDefaultQuery: jest.fn(() => ({ query: '', language: 'kuery' })),
+ };
+}
+
+function createMockTimefilter() {
+ const unsubscribe = jest.fn();
+
+ let timeFilter = { from: 'now-7d', to: 'now' };
+ let subscriber: () => void;
+ return {
+ getTime: jest.fn(() => timeFilter),
+ setTime: jest.fn((newTimeFilter) => {
+ timeFilter = newTimeFilter;
+ if (subscriber) {
+ subscriber();
+ }
+ }),
+ getTimeUpdate$: () => ({
+ subscribe: ({ next }: { next: () => void }) => {
+ subscriber = next;
+ return unsubscribe;
+ },
+ }),
+ calculateBounds: jest.fn(() => ({
+ min: moment('2021-01-10T04:00:00.000Z'),
+ max: moment('2021-01-10T08:00:00.000Z'),
+ })),
+ getBounds: jest.fn(() => timeFilter),
+ getRefreshInterval: () => {},
+ getRefreshIntervalDefaults: () => {},
+ getAutoRefreshFetch$: () => new Observable(),
+ };
+}
+
+function makeDefaultData(): jest.Mocked {
+ return ({
+ query: {
+ filterManager: createMockFilterManager(),
+ timefilter: {
+ timefilter: createMockTimefilter(),
+ },
+ queryString: createMockQueryString(),
+ state$: new Observable(),
+ },
+ indexPatterns: {
+ get: jest.fn((id) => {
+ return new Promise((resolve) => resolve({ id }));
+ }),
+ },
+ search: createMockSearchService(),
+ nowProvider: {
+ get: jest.fn(),
+ },
+ } as unknown) as DataPublicPluginStart;
+}
+
+const createMiddleware = (data: DataPublicPluginStart) => {
+ const middleware = timeRangeMiddleware(data);
+ const store = {
+ getState: jest.fn(() => ({ app: initialState })),
+ dispatch: jest.fn(),
+ };
+ const next = jest.fn();
+
+ const invoke = (action: PayloadAction>) => middleware(store)(next)(action);
+
+ return { store, next, invoke };
+};
+
+describe('timeRangeMiddleware', () => {
+ describe('time update', () => {
+ it('does update the searchSessionId when the state changes and too much time passed', () => {
+ 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: 'app/setState',
+ payload: { lastKnownDoc: ('new' as unknown) as Document },
+ };
+ invoke(action);
+ expect(store.dispatch).toHaveBeenCalledWith({
+ payload: {
+ resolvedDateRange: {
+ fromDate: '2021-01-10T04:00:00.000Z',
+ toDate: '2021-01-10T08:00:00.000Z',
+ },
+ searchSessionId: 'sessionId-1',
+ },
+ type: 'app/setState',
+ });
+ expect(next).toHaveBeenCalledWith(action);
+ });
+ it('does not update the searchSessionId when the state changes and too little time has passed', () => {
+ const data = makeDefaultData();
+ // time range is 100,000ms ago to 300ms ago (that's a lag of .3 percent, not enough to trigger a session update)
+ (data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 300));
+ (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() - 300),
+ });
+ const { next, invoke, store } = createMiddleware(data);
+ const action = {
+ type: 'app/setState',
+ payload: { lastKnownDoc: ('new' as unknown) as Document },
+ };
+ invoke(action);
+ expect(store.dispatch).not.toHaveBeenCalled();
+ expect(next).toHaveBeenCalledWith(action);
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/state_management/time_range_middleware.ts b/x-pack/plugins/lens/public/state_management/time_range_middleware.ts
new file mode 100644
index 0000000000000..a6c868be60565
--- /dev/null
+++ b/x-pack/plugins/lens/public/state_management/time_range_middleware.ts
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { isEqual } 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';
+
+export const timeRangeMiddleware = (data: DataPublicPluginStart) => (store: MiddlewareAPI) => {
+ return (next: Dispatch) => (action: PayloadAction>) => {
+ // 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)
+ ) {
+ updateTimeRange(data, store.dispatch);
+ }
+ next(action);
+ };
+};
+function updateTimeRange(data: DataPublicPluginStart, dispatch: LensDispatch) {
+ const timefilter = data.query.timefilter.timefilter;
+ const unresolvedTimeRange = timefilter.getTime();
+ if (
+ !containsDynamicMath(unresolvedTimeRange.from) &&
+ !containsDynamicMath(unresolvedTimeRange.to)
+ ) {
+ return;
+ }
+
+ const { min, max } = timefilter.getBounds();
+
+ if (!min || !max) {
+ // bounds not fully specified, bailing out
+ return;
+ }
+
+ // calculate length of currently configured range in ms
+ const timeRangeLength = moment.duration(max.diff(min)).asMilliseconds();
+
+ // calculate lag of managed "now" for date math
+ const nowDiff = Date.now() - data.nowProvider.get().valueOf();
+
+ // if the lag is signifcant, start a new session to clear the cache
+ if (nowDiff > timeRangeLength * TIME_LAG_PERCENTAGE_LIMIT) {
+ dispatch(
+ setState({
+ searchSessionId: data.search.session.start(),
+ resolvedDateRange: getResolvedDateRange(timefilter),
+ })
+ );
+ }
+}
diff --git a/x-pack/plugins/lens/public/state_management/types.ts b/x-pack/plugins/lens/public/state_management/types.ts
new file mode 100644
index 0000000000000..87045d15cc994
--- /dev/null
+++ b/x-pack/plugins/lens/public/state_management/types.ts
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { Filter, IndexPattern, 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 {
+ 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;
+ query: Query;
+ filters: Filter[];
+ savedQuery?: SavedQuery;
+ searchSessionId: string;
+ resolvedDateRange: DateRange;
+}
+
+export type DispatchSetState = (
+ state: Partial
+) => {
+ payload: Partial;
+ type: string;
+};
+
+export interface LensState {
+ app: LensAppState;
+}
diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts
index 9cde4eb8a1561..5a632e03f8f36 100644
--- a/x-pack/plugins/lens/public/types.ts
+++ b/x-pack/plugins/lens/public/types.ts
@@ -18,9 +18,8 @@ import {
SerializedFieldFormat,
} from '../../../../src/plugins/expressions/public';
import { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop';
-import { Document } from './persistence';
import { DateRange } from '../common';
-import { Query, Filter, SavedQuery, IFieldFormat } from '../../../../src/plugins/data/public';
+import { Query, Filter, IFieldFormat } from '../../../../src/plugins/data/public';
import { VisualizeFieldContext } from '../../../../src/plugins/ui_actions/public';
import { RangeSelectContext, ValueClickContext } from '../../../../src/plugins/embeddable/public';
import {
@@ -46,22 +45,7 @@ export interface PublicAPIProps {
export interface EditorFrameProps {
onError: ErrorCallback;
- doc?: Document;
- dateRange: DateRange;
- query: Query;
- filters: Filter[];
- savedQuery?: SavedQuery;
- searchSessionId: string;
initialContext?: VisualizeFieldContext;
-
- // Frame loader (app or embeddable) is expected to call this when it loads and updates
- // This should be replaced with a top-down state
- onChange: (newState: {
- filterableIndexPatterns: string[];
- doc: Document;
- isSaveable: boolean;
- activeData?: Record;
- }) => void;
showNoDataPopover: () => void;
}
export interface EditorFrameInstance {
diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts
index 2d8cfee2185fa..c1aab4c18f529 100644
--- a/x-pack/plugins/lens/public/utils.ts
+++ b/x-pack/plugins/lens/public/utils.ts
@@ -6,6 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
+import { IndexPattern, IndexPatternsContract, TimefilterContract } from 'src/plugins/data/public';
import { LensFilterEvent } from './types';
/** replaces the value `(empty) to empty string for proper filtering` */
@@ -49,3 +50,32 @@ export function getVisualizeGeoFieldMessage(fieldType: string) {
values: { fieldType },
});
}
+
+export const getResolvedDateRange = function (timefilter: TimefilterContract) {
+ const { from, to } = timefilter.getTime();
+ const { min, max } = timefilter.calculateBounds({
+ from,
+ to,
+ });
+ return { fromDate: min?.toISOString() || from, toDate: max?.toISOString() || to };
+};
+
+export function containsDynamicMath(dateMathString: string) {
+ return dateMathString.includes('now');
+}
+export const TIME_LAG_PERCENTAGE_LIMIT = 0.02;
+
+export async function getAllIndexPatterns(
+ ids: string[],
+ indexPatternsService: IndexPatternsContract
+): Promise<{ indexPatterns: IndexPattern[]; rejectedIds: string[] }> {
+ const responses = await Promise.allSettled(ids.map((id) => indexPatternsService.get(id)));
+ const fullfilled = responses.filter(
+ (response): response is PromiseFulfilledResult => response.status === 'fulfilled'
+ );
+ const rejectedIds = responses
+ .map((_response, i) => ids[i])
+ .filter((id, i) => responses[i].status === 'rejected');
+ // return also the rejected ids in case we want to show something later on
+ return { indexPatterns: fullfilled.map((response) => response.value), rejectedIds };
+}
diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
index dda1a444f4544..19cfcb1a60cc7 100644
--- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
@@ -6,7 +6,7 @@
*/
import React from 'react';
-import _ from 'lodash';
+import { uniq } from 'lodash';
import { render } from 'react-dom';
import { Position } from '@elastic/charts';
import { I18nProvider } from '@kbn/i18n/react';
@@ -43,7 +43,7 @@ function getVisualizationType(state: State): VisualizationType | 'mixed' {
);
}
const visualizationType = visualizationTypes.find((t) => t.id === state.layers[0].seriesType);
- const seriesTypes = _.uniq(state.layers.map((l) => l.seriesType));
+ const seriesTypes = uniq(state.layers.map((l) => l.seriesType));
return visualizationType && seriesTypes.length === 1 ? visualizationType : 'mixed';
}
@@ -111,7 +111,7 @@ export const getXyVisualization = ({
},
appendLayer(state, layerId) {
- const usedSeriesTypes = _.uniq(state.layers.map((layer) => layer.seriesType));
+ const usedSeriesTypes = uniq(state.layers.map((layer) => layer.seriesType));
return {
...state,
layers: [
@@ -255,10 +255,11 @@ export const getXyVisualization = ({
},
setDimension({ prevState, layerId, columnId, groupId }) {
- const newLayer = prevState.layers.find((l) => l.layerId === layerId);
- if (!newLayer) {
+ const foundLayer = prevState.layers.find((l) => l.layerId === layerId);
+ if (!foundLayer) {
return prevState;
}
+ const newLayer = { ...foundLayer };
if (groupId === 'x') {
newLayer.xAccessor = columnId;
@@ -277,11 +278,11 @@ export const getXyVisualization = ({
},
removeDimension({ prevState, layerId, columnId }) {
- const newLayer = prevState.layers.find((l) => l.layerId === layerId);
- if (!newLayer) {
+ const foundLayer = prevState.layers.find((l) => l.layerId === layerId);
+ if (!foundLayer) {
return prevState;
}
-
+ const newLayer = { ...foundLayer };
if (newLayer.xAccessor === columnId) {
delete newLayer.xAccessor;
} else if (newLayer.splitAccessor === columnId) {
diff --git a/yarn.lock b/yarn.lock
index 3add4843d0966..a92dadf08dde7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3587,6 +3587,16 @@
resolved "https://registry.yarnpkg.com/@redux-saga/types/-/types-1.1.0.tgz#0e81ce56b4883b4b2a3001ebe1ab298b84237204"
integrity sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg==
+"@reduxjs/toolkit@^1.5.1":
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.5.1.tgz#05daa2f6eebc70dc18cd98a90421fab7fa565dc5"
+ integrity sha512-PngZKuwVZsd+mimnmhiOQzoD0FiMjqVks6ituO1//Ft5UEX5Ca9of13NEjo//pU22Jk7z/mdXVsmDfgsig1osA==
+ dependencies:
+ immer "^8.0.1"
+ redux "^4.0.0"
+ redux-thunk "^2.3.0"
+ reselect "^4.0.0"
+
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f"
@@ -5793,6 +5803,13 @@
resolved "https://registry.yarnpkg.com/@types/redux-actions/-/redux-actions-2.6.1.tgz#0940e97fa35ad3004316bddb391d8e01d2efa605"
integrity sha512-zKgK+ATp3sswXs6sOYo1tk8xdXTy4CTaeeYrVQlClCjeOpag5vzPo0ASWiiBJ7vsiQRAdb3VkuFLnDoBimF67g==
+"@types/redux-logger@^3.0.8":
+ version "3.0.8"
+ resolved "https://registry.yarnpkg.com/@types/redux-logger/-/redux-logger-3.0.8.tgz#1fb6d26917bb198792bb1cf57feb31cae1532c5d"
+ integrity sha512-zM+cxiSw6nZtRbxpVp9SE3x/X77Z7e7YAfHD1NkxJyJbAGSXJGF0E9aqajZfPOa/sTYnuwutmlCldveExuCeLw==
+ dependencies:
+ redux "^4.0.0"
+
"@types/request@^2.48.2":
version "2.48.2"
resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.2.tgz#936374cbe1179d7ed529fc02543deb4597450fed"
@@ -11284,6 +11301,11 @@ dedent@^0.7.0:
resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c"
integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=
+deep-diff@^0.3.5:
+ version "0.3.8"
+ resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84"
+ integrity sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=
+
deep-eql@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-0.1.3.tgz#ef558acab8de25206cd713906d74e56930eb69f2"
@@ -23625,6 +23647,13 @@ redux-devtools-extension@^2.13.8:
resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz#37b982688626e5e4993ff87220c9bbb7cd2d96e1"
integrity sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg==
+redux-logger@^3.0.6:
+ version "3.0.6"
+ resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf"
+ integrity sha1-91VZZvMJjzyIYExEnPC69XeCdL8=
+ dependencies:
+ deep-diff "^0.3.5"
+
redux-observable@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-1.2.0.tgz#ff51b6c6be2598e9b5e89fc36639186bb0e669c7"