From a4f6d43783f556f00ceef33e458049fd3715aeb6 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Fri, 28 May 2021 10:06:06 +0200 Subject: [PATCH 1/9] [Lens] Move app state to redux toolkit (#100338) --- package.json | 3 + .../lens/public/app_plugin/app.test.tsx | 1164 +++++-------- x-pack/plugins/lens/public/app_plugin/app.tsx | 533 +----- .../lens/public/app_plugin/lens_top_nav.tsx | 199 ++- .../lens/public/app_plugin/mounter.test.tsx | 150 ++ .../lens/public/app_plugin/mounter.tsx | 153 +- .../lens/public/app_plugin/time_range.ts | 84 - .../plugins/lens/public/app_plugin/types.ts | 57 +- .../editor_frame/editor_frame.test.tsx | 1501 ++++++++--------- .../editor_frame/editor_frame.tsx | 175 +- .../editor_frame_service/editor_frame/save.ts | 7 +- .../editor_frame/state_management.test.ts | 11 +- .../editor_frame/state_management.ts | 43 +- .../editor_frame/suggestion_helpers.ts | 8 +- .../workspace_panel/workspace_panel.test.tsx | 224 ++- .../workspace_panel/workspace_panel.tsx | 10 +- .../public/editor_frame_service/service.tsx | 31 +- x-pack/plugins/lens/public/mocks.tsx | 276 +++ .../shared_components/debounced_value.ts | 4 +- .../lens/public/state_management/app_slice.ts | 55 + .../external_context_middleware.ts | 103 ++ .../lens/public/state_management/index.ts | 76 + .../time_range_middleware.test.ts | 198 +++ .../state_management/time_range_middleware.ts | 61 + .../lens/public/state_management/types.ts | 42 + x-pack/plugins/lens/public/types.ts | 18 +- x-pack/plugins/lens/public/utils.ts | 30 + .../public/xy_visualization/visualization.tsx | 17 +- yarn.lock | 29 + 29 files changed, 2909 insertions(+), 2353 deletions(-) create mode 100644 x-pack/plugins/lens/public/app_plugin/mounter.test.tsx delete mode 100644 x-pack/plugins/lens/public/app_plugin/time_range.ts create mode 100644 x-pack/plugins/lens/public/state_management/app_slice.ts create mode 100644 x-pack/plugins/lens/public/state_management/external_context_middleware.ts create mode 100644 x-pack/plugins/lens/public/state_management/index.ts create mode 100644 x-pack/plugins/lens/public/state_management/time_range_middleware.test.ts create mode 100644 x-pack/plugins/lens/public/state_management/time_range_middleware.ts create mode 100644 x-pack/plugins/lens/public/state_management/types.ts diff --git a/package.json b/package.json index 1369b1d105aa45..627e8abd9d259c 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 72b8bfa38491ac..30b4e2d954d2b8 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 c172f36913c217..61ed2934a40011 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 f90a21b2818d47..245e964bbd2e67 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 00000000000000..f2640c5c32acf9 --- /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 e6eb115562d378..708573e843fcf4 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 c9e507f3e6f132..00000000000000 --- 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 c9143542e67bfb..72850552723f33 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 f23e4c74e1a8be..351b4009240ebb 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 91b59664ada838..4710e03d336bcf 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 6eec13dd9d7ce0..86a28be65d2b9f 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 5d6dae557dbb8e..af8a9c0a855588 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 53aba0d6f3f6c1..aa365d1e66d6c5 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 83b09226265423..bd8f134f59fbb7 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 baa9d45a431eaf..1d248c4411023c 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 3d5d9a6d84d811..94065f316340cb 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 f6500596ce5a0e..62274df23e837b 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 c1f885d167659d..473c170aef2948 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 5447384ce38ea5..1f8ba0fa765b2e 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 00000000000000..29d5b0bee843f6 --- /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 00000000000000..35d0f7cf197edd --- /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 00000000000000..429978e60756b8 --- /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 00000000000000..4145f8ed5e52cf --- /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 00000000000000..a6c868be605650 --- /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 00000000000000..87045d15cc9946 --- /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 9cde4eb8a15616..5a632e03f8f36c 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 2d8cfee2185fa2..c1aab4c18f5297 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 dda1a444f45448..19cfcb1a60cc7c 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 3add4843d0966b..a92dadf08dde74 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" From 79945fe0275b2ec9c93747e26154110133ec51fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Fri, 28 May 2021 14:41:42 +0200 Subject: [PATCH 2/9] [Observability] Fix typo in readme for new navigation (#100861) * [Observability] Fix typo in readme for new navigation * Add rxjs dep --- .../public/components/shared/page_template/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/observability/public/components/shared/page_template/README.md b/x-pack/plugins/observability/public/components/shared/page_template/README.md index e360e6d95a9d8a..fb2a603cc7a7fa 100644 --- a/x-pack/plugins/observability/public/components/shared/page_template/README.md +++ b/x-pack/plugins/observability/public/components/shared/page_template/README.md @@ -17,6 +17,8 @@ Now within your solution's **public** plugin `setup` lifecycle method you can ca ```typescript // x-pack/plugins/example_plugin/public/plugin.ts +import { of } from 'rxjs'; + export class Plugin implements PluginClass { constructor(_context: PluginInitializerContext) {} @@ -64,7 +66,7 @@ This can be accessed like so: ``` const [coreStart, pluginsStart] = await core.getStartServices(); -const pageTemplateComponent = pluginsStart.observability.navigation.PageTemplate; +const ObservabilityPageTemplate = pluginsStart.observability.navigation.PageTemplate; ``` Now that you have access to the component you can render your solution's content using it. @@ -101,4 +103,4 @@ The `` component is a wrapper around the ` Date: Fri, 28 May 2021 08:42:44 -0400 Subject: [PATCH 3/9] [Infra] Update LogStream component docs (#100795) --- .../log_stream/log_stream.stories.mdx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx b/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx index 901a4b6a8383e3..7f1636b00d24e3 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx @@ -113,7 +113,7 @@ export const Template = (args) => ; The purpose of this component is to allow you, the developer, to have your very own Log Stream in your plugin. -The component is exposed through `infra/public`. Since Kibana uses relative paths is up to you to find how to import it (sorry). +The component is exposed through `infra/public`. Since Kibana uses relative paths, it is up to you to find how to import it (sorry). ```tsx import { LogStream } from '../../../../../../infra/public'; @@ -124,8 +124,9 @@ import { LogStream } from '../../../../../../infra/public'; To use the component your plugin needs to follow certain criteria: -- Ensure `"infra"` is specified as a `requiredPlugins` in your plugin's `kibana.json`. -- Ensure the `` component is mounted inside the hiearchy of a [`kibana-react` provider](https://github.com/elastic/kibana/blob/b2d0aa7b7fae1c89c8f9e8854ae73e71be64e765/src/plugins/kibana_react/README.md#L45). +- Ensure `"infra"` and `"data"` are specified as a `requiredPlugins` in your plugin's `kibana.json`. +- Ensure the `` component is mounted inside the hierachy of a [`kibana-react` provider](https://github.com/elastic/kibana/blob/b2d0aa7b7fae1c89c8f9e8854ae73e71be64e765/src/plugins/kibana_react/README.md#L45). At a minimum, the kibana-react provider must pass `http` (from core start services) and `data` (from core plugin start dependencies). +- Ensure the `` component is mounted inside the hierachy of a [`EuiThemeProvider`](https://github.com/elastic/kibana/blob/master/src/plugins/kibana_react/common/eui_styled_components.tsx). ## Usage @@ -354,25 +355,30 @@ The infra plugin has the concept of a "source configuration", a collection of se The `` component will use the `"default"` source configuration. If you want to use your own configuration, you need to first create it when you initialize your plugin, and then specify it in the `` component with the `sourceId` prop. ```tsx -// Your `plugin/init.ts` +// Your `server/plugin.ts` class MyPlugin { // ... setup(core, plugins) { plugins.infra.defineInternalSourceConfiguration( 'my_source', // ID for your source configuration { - logAlias: 'some-index-*', // Optional. what ES index to query. + name: 'some-name', + description: 'some description', + logIndices: { // Also accepts an `index_pattern` type with `indexPatternId` + type: 'index_name', + indexName: 'some-index', + }, logColumns: [ - { timestampColumn: { id: '...uuid4' }, // The `@timestamp` column. - { fieldColumn: { id: '...uuid4', field: 'some_field' }}, // Any column(s) you want. - { messageColumn: { id: '...uuid' }} // The `message` column. + { timestampColumn: { id: '...uuid4' }, // The `@timestamp` column. `id` is an arbitrary string identifier. + { fieldColumn: { id: '...uuid4', field: 'some_field' }}, // Any column(s) you want. `id` is an arbitrary string identifier. + { messageColumn: { id: '...uuid' }} // The `message` column. `id` is an arbitrary string identifier. ] } ); } } -// Somewhere else on your code +// Somewhere else in your client-side code Date: Fri, 28 May 2021 15:19:15 +0200 Subject: [PATCH 4/9] [Security Solution][Endpoint] Do not display searchbar in security-trusted apps if there are no items (#100853) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/search_bar/index.tsx | 2 +- .../view/trusted_apps_page.test.tsx | 17 +++++- .../trusted_apps/view/trusted_apps_page.tsx | 54 ++++++++++--------- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/components/search_bar/index.tsx b/x-pack/plugins/security_solution/public/management/components/search_bar/index.tsx index 3c92ab31680c20..5ace2b901da11c 100644 --- a/x-pack/plugins/security_solution/public/management/components/search_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/management/components/search_bar/index.tsx @@ -25,7 +25,7 @@ export const SearchBar = memo(({ defaultValue = '', onSearch, pl const handleOnSearch = useCallback(() => onSearch(query), [query, onSearch]); return ( - + { it('should display a Add Trusted App button', async () => { const { getByTestId } = await renderWithListData(); - const addButton = await getByTestId('trustedAppsListAddButton'); + const addButton = getByTestId('trustedAppsListAddButton'); expect(addButton.textContent).toBe('Add Trusted Application'); }); + it('should display the searchbar', async () => { + const renderResult = await renderWithListData(); + expect(await renderResult.findByTestId('searchBar')).not.toBeNull(); + }); + describe('and the Grid view is being displayed', () => { describe('and the edit trusted app button is clicked', () => { let renderResult: ReturnType; @@ -555,7 +560,7 @@ describe('When on the Trusted Apps Page', () => { // to test the UI behaviours while the API call is in flight coreStart.http.post.mockImplementation( // @ts-ignore - async (path: string, options: HttpFetchOptions) => { + async (_, options: HttpFetchOptions) => { return new Promise((resolve, reject) => { httpPostBody = options.body as string; resolveHttpPost = resolve; @@ -861,6 +866,14 @@ describe('When on the Trusted Apps Page', () => { expect(await renderResult.findByTestId('trustedAppEmptyState')).not.toBeNull(); }); + + it('should not display the searchbar', async () => { + const renderResult = render(); + await act(async () => { + await waitForAction('trustedAppsExistStateChanged'); + }); + expect(renderResult.queryByTestId('searchBar')).toBeNull(); + }); }); describe('and the search is dispatched', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx index ac06254a531001..5603b8e2d61c90 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx @@ -96,34 +96,36 @@ export const TrustedAppsPage = memo(() => { /> )} - {doEntriesExist ? ( - - - - - + <> + + - - - - - {location.view_type === 'grid' && } - {location.view_type === 'list' && } - - + + + + + + + + + {location.view_type === 'grid' && } + {location.view_type === 'list' && } + + + ) : ( )} From 51616e1b8d72d7186b2509339f629f8891ea85e3 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Fri, 28 May 2021 15:24:28 +0200 Subject: [PATCH 5/9] [Lens] Adds dynamic table cell coloring (#95217) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Greg Thompson Co-authored-by: Michael Marcialis --- src/plugins/charts/common/palette.test.ts | 25 +- src/plugins/charts/common/palette.ts | 60 ++- .../public/services/palettes/helpers.test.ts | 307 ++++++++++++++ .../public/services/palettes/helpers.ts | 101 +++++ .../charts/public/services/palettes/mock.ts | 37 +- .../services/palettes/palettes.test.tsx | 111 +++-- .../public/services/palettes/palettes.tsx | 81 +++- .../charts/public/services/palettes/types.ts | 27 +- .../components/controls/palette_picker.tsx | 4 +- .../application/components/palette_picker.tsx | 8 +- .../lib/get_split_by_terms_color.ts | 2 +- .../vis_type_xy/public/vis_component.tsx | 2 +- .../canvas/public/functions/pie.test.js | 4 +- x-pack/plugins/canvas/public/functions/pie.ts | 2 +- .../canvas/public/functions/plot.test.js | 4 +- .../canvas/public/functions/plot/index.ts | 2 +- .../__snapshots__/table_basic.test.tsx.snap | 21 + .../components/cell_value.test.tsx | 117 ++++- .../components/cell_value.tsx | 82 +++- .../components/dimension_editor.scss | 7 + .../components/dimension_editor.test.tsx | 119 +++++- .../components/dimension_editor.tsx | 223 +++++++++- .../components/palette_panel_container.scss | 53 +++ .../components/palette_panel_container.tsx | 112 +++++ .../components/shared_utils.tsx | 36 ++ .../components/table_basic.test.tsx | 56 +++ .../components/table_basic.tsx | 37 +- .../components/types.ts | 10 + .../datatable_visualization/expression.tsx | 19 +- .../public/datatable_visualization/index.ts | 11 +- .../transpose_helpers.ts | 22 +- .../visualization.test.tsx | 45 +- .../datatable_visualization/visualization.tsx | 93 ++-- .../config_panel/dimension_container.tsx | 124 +++--- .../editor_frame/config_panel/layer_panel.tsx | 5 +- .../public/editor_frame_service/mocks.tsx | 5 +- .../calculations/moving_average.tsx | 8 +- .../operations/definitions/helpers.tsx | 24 -- .../operations/definitions/percentile.tsx | 2 +- .../definitions/ranges/advanced_editor.tsx | 3 +- .../definitions/ranges/range_editor.tsx | 2 +- .../definitions/terms/values_input.tsx | 2 +- .../render_function.test.tsx | 2 +- .../pie_visualization/render_function.tsx | 2 +- .../pie_visualization/visualization.tsx | 2 +- .../coloring/color_stops.test.tsx | 156 +++++++ .../coloring/color_stops.tsx | 294 +++++++++++++ .../shared_components/coloring/constants.ts | 29 ++ .../shared_components/coloring/index.ts | 12 + .../coloring/palette_configuration.scss | 7 + .../coloring/palette_configuration.test.tsx | 185 ++++++++ .../coloring/palette_configuration.tsx | 340 +++++++++++++++ .../coloring/palette_picker.tsx | 109 +++++ .../shared_components/coloring/types.ts | 25 ++ .../shared_components/coloring/utils.test.ts | 399 ++++++++++++++++++ .../shared_components/coloring/utils.ts | 308 ++++++++++++++ .../lens/public/shared_components/helpers.ts | 31 ++ .../lens/public/shared_components/index.ts | 3 + .../shared_components/palette_picker.tsx | 47 +-- .../tooltip_wrapper.tsx | 0 x-pack/plugins/lens/public/types.ts | 4 +- .../xy_visualization/color_assignment.ts | 2 +- .../public/xy_visualization/expression.tsx | 2 +- .../visual_options_popover.tsx | 3 +- .../xy_visualization/visualization.test.ts | 16 +- .../public/xy_visualization/visualization.tsx | 2 +- .../xy_visualization/xy_config_panel.test.tsx | 4 + .../xy_visualization/xy_config_panel.tsx | 3 +- x-pack/plugins/maps/public/kibana_services.ts | 2 +- x-pack/test/accessibility/apps/lens.ts | 23 + x-pack/test/functional/apps/lens/table.ts | 51 +++ .../test/functional/page_objects/lens_page.ts | 55 ++- 72 files changed, 3792 insertions(+), 341 deletions(-) create mode 100644 src/plugins/charts/public/services/palettes/helpers.test.ts create mode 100644 src/plugins/charts/public/services/palettes/helpers.ts create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.scss create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.scss create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.tsx create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/shared_utils.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/constants.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/index.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.scss create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/types.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts create mode 100644 x-pack/plugins/lens/public/shared_components/coloring/utils.ts create mode 100644 x-pack/plugins/lens/public/shared_components/helpers.ts rename x-pack/plugins/lens/public/{xy_visualization => shared_components}/tooltip_wrapper.tsx (100%) diff --git a/src/plugins/charts/common/palette.test.ts b/src/plugins/charts/common/palette.test.ts index 0a26d71a9b9d53..86ba74d409cc6e 100644 --- a/src/plugins/charts/common/palette.test.ts +++ b/src/plugins/charts/common/palette.test.ts @@ -12,13 +12,14 @@ import { systemPalette, PaletteOutput, CustomPaletteState, + CustomPaletteArguments, } from './palette'; import { functionWrapper } from 'src/plugins/expressions/common/expression_functions/specs/tests/utils'; describe('palette', () => { const fn = functionWrapper(palette()) as ( context: null, - args?: { color?: string[]; gradient?: boolean; reverse?: boolean } + args?: Partial ) => PaletteOutput; it('results a palette', () => { @@ -39,6 +40,18 @@ describe('palette', () => { }); }); + describe('stop', () => { + it('sets stops', () => { + const result = fn(null, { color: ['red', 'green', 'blue'], stop: [1, 2, 3] }); + expect(result.params!.stops).toEqual([1, 2, 3]); + }); + + it('defaults to pault_tor_14 colors', () => { + const result = fn(null); + expect(result.params!.colors).toEqual(defaultCustomColors); + }); + }); + describe('gradient', () => { it('sets gradient', () => { let result = fn(null, { gradient: true }); @@ -69,6 +82,16 @@ describe('palette', () => { const result = fn(null); expect(result.params!.colors).toEqual(defaultCustomColors); }); + + it('keeps the stops order pristine when set', () => { + const stops = [1, 2, 3]; + const result = fn(null, { + color: ['red', 'green', 'blue'], + stop: [1, 2, 3], + reverse: true, + }); + expect(result.params!.stops).toEqual(stops); + }); }); }); }); diff --git a/src/plugins/charts/common/palette.ts b/src/plugins/charts/common/palette.ts index c9232b22cfae14..78c6fcc8120284 100644 --- a/src/plugins/charts/common/palette.ts +++ b/src/plugins/charts/common/palette.ts @@ -14,11 +14,21 @@ export interface CustomPaletteArguments { color?: string[]; gradient: boolean; reverse?: boolean; + stop?: number[]; + range?: 'number' | 'percent'; + rangeMin?: number; + rangeMax?: number; + continuity?: 'above' | 'below' | 'all' | 'none'; } export interface CustomPaletteState { colors: string[]; gradient: boolean; + stops: number[]; + range: 'number' | 'percent'; + rangeMin: number; + rangeMax: number; + continuity?: 'above' | 'below' | 'all' | 'none'; } export interface SystemPaletteArguments { @@ -83,6 +93,35 @@ export function palette(): ExpressionFunctionDefinition< }), required: false, }, + stop: { + multi: true, + types: ['number'], + help: i18n.translate('charts.functions.palette.args.stopHelpText', { + defaultMessage: + 'The palette color stops. When used, it must be associated with each color.', + }), + required: false, + }, + continuity: { + types: ['string'], + options: ['above', 'below', 'all', 'none'], + default: 'above', + help: '', + }, + rangeMin: { + types: ['number'], + help: '', + }, + rangeMax: { + types: ['number'], + help: '', + }, + range: { + types: ['string'], + options: ['number', 'percent'], + default: 'percent', + help: '', + }, gradient: { types: ['boolean'], default: false, @@ -101,15 +140,32 @@ export function palette(): ExpressionFunctionDefinition< }, }, fn: (input, args) => { - const { color, reverse, gradient } = args; + const { + color, + continuity, + reverse, + gradient, + stop, + range, + rangeMin = 0, + rangeMax = 100, + } = args; const colors = ([] as string[]).concat(color || defaultCustomColors); - + const stops = ([] as number[]).concat(stop || []); + if (stops.length > 0 && colors.length !== stops.length) { + throw Error('When stop is used, each color must have an associated stop value.'); + } return { type: 'palette', name: 'custom', params: { colors: reverse ? colors.reverse() : colors, + stops, + range: range ?? 'percent', gradient, + continuity, + rangeMin, + rangeMax, }, }; }, diff --git a/src/plugins/charts/public/services/palettes/helpers.test.ts b/src/plugins/charts/public/services/palettes/helpers.test.ts new file mode 100644 index 00000000000000..90f5745570cc8b --- /dev/null +++ b/src/plugins/charts/public/services/palettes/helpers.test.ts @@ -0,0 +1,307 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { workoutColorForValue } from './helpers'; +import { CustomPaletteState } from '../..'; + +describe('workoutColorForValue', () => { + it('should return no color for empty value', () => { + expect( + workoutColorForValue( + undefined, + { + continuity: 'above', + colors: ['red', 'green', 'blue', 'yellow'], + range: 'number', + gradient: false, + rangeMin: 0, + rangeMax: 200, + stops: [], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + }); + + describe('range: "number"', () => { + const DEFAULT_PROPS: CustomPaletteState = { + continuity: 'above', + colors: ['red', 'green', 'blue', 'yellow'], + range: 'number', + gradient: false, + rangeMin: 0, + rangeMax: 200, + stops: [], + }; + it('find the right color for predefined palettes', () => { + expect(workoutColorForValue(123, DEFAULT_PROPS, { min: 0, max: 200 })).toBe('blue'); + }); + + it('find the right color for custom stops palettes', () => { + expect( + workoutColorForValue( + 50, + { + ...DEFAULT_PROPS, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('blue'); + }); + + it('find the right color for custom stops palettes when value is higher than rangeMax', () => { + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('yellow'); + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + continuity: 'all', + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('yellow'); + }); + + it('returns no color if the value if higher than rangeMax and continuity is nor "above" or "all"', () => { + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + continuity: 'below', + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + continuity: 'none', + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + }); + + it('find the right color for custom stops palettes when value is lower than rangeMin', () => { + expect( + workoutColorForValue( + 10, + { + ...DEFAULT_PROPS, + continuity: 'below', + rangeMin: 20, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('red'); + expect( + workoutColorForValue( + 10, + { + ...DEFAULT_PROPS, + continuity: 'all', + rangeMin: 20, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('red'); + }); + + it('returns no color if the value if lower than rangeMin and continuity is nor "below" or "all"', () => { + expect( + workoutColorForValue( + 0, + { + ...DEFAULT_PROPS, + rangeMin: 10, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + expect( + workoutColorForValue( + 0, + { + ...DEFAULT_PROPS, + continuity: 'none', + rangeMin: 10, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + }); + }); + + describe('range: "percent"', () => { + const DEFAULT_PROPS: CustomPaletteState = { + continuity: 'above', + colors: ['red', 'green', 'blue', 'yellow'], + range: 'percent', + gradient: false, + rangeMin: 0, + rangeMax: 100, + stops: [], + }; + it('find the right color for predefined palettes', () => { + expect(workoutColorForValue(123, DEFAULT_PROPS, { min: 0, max: 200 })).toBe('blue'); + }); + + it('find the right color for custom stops palettes', () => { + expect( + workoutColorForValue( + 113, + { + ...DEFAULT_PROPS, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('blue'); // 113/200 ~ 56% + }); + + it('find the right color for custom stops palettes when value is higher than rangeMax', () => { + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + rangeMax: 90, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('yellow'); + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + continuity: 'all', + rangeMax: 90, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('yellow'); + }); + + it('returns no color if the value if higher than rangeMax and continuity is nor "above" or "all"', () => { + expect( + workoutColorForValue( + 190, + { + ...DEFAULT_PROPS, + continuity: 'below', + rangeMax: 90, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + expect( + workoutColorForValue( + 190, + { + ...DEFAULT_PROPS, + continuity: 'none', + rangeMax: 90, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + }); + + it('find the right color for custom stops palettes when value is lower than rangeMin', () => { + expect( + workoutColorForValue( + 10, + { + ...DEFAULT_PROPS, + continuity: 'below', + rangeMin: 20, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('red'); + expect( + workoutColorForValue( + 10, + { + ...DEFAULT_PROPS, + continuity: 'all', + rangeMin: 20, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('red'); + }); + + it('returns no color if the value if lower than rangeMin and continuity is nor "below" or "all"', () => { + expect( + workoutColorForValue( + 0, + { + ...DEFAULT_PROPS, + continuity: 'above', + rangeMin: 10, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + expect( + workoutColorForValue( + 0, + { + ...DEFAULT_PROPS, + continuity: 'none', + rangeMin: 10, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + }); + }); +}); diff --git a/src/plugins/charts/public/services/palettes/helpers.ts b/src/plugins/charts/public/services/palettes/helpers.ts new file mode 100644 index 00000000000000..d4b1e98f94cc8a --- /dev/null +++ b/src/plugins/charts/public/services/palettes/helpers.ts @@ -0,0 +1,101 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CustomPaletteState } from '../..'; + +function findColorSegment( + value: number, + comparison: (value: number, bucket: number) => number, + colors: string[], + rangeMin: number, + rangeMax: number +) { + // assume uniform distribution within the provided range, can ignore stops + const step = (rangeMax - rangeMin) / colors.length; + + // what about values in range + const index = colors.findIndex((c, i) => comparison(value, rangeMin + (1 + i) * step) <= 0); + return colors[index] || colors[0]; +} + +function findColorsByStops( + value: number, + comparison: (value: number, bucket: number) => number, + colors: string[], + stops: number[] +) { + const index = stops.findIndex((s) => comparison(value, s) < 0); + return colors[index] || colors[0]; +} + +function getNormalizedValueByRange( + value: number, + { range }: CustomPaletteState, + minMax: { min: number; max: number } +) { + let result = value; + if (range === 'percent') { + result = (100 * (value - minMax.min)) / (minMax.max - minMax.min); + } + // for a range of 1 value the formulas above will divide by 0, so here's a safety guard + if (Number.isNaN(result)) { + return 1; + } + return result; +} + +/** + * When stops are empty, it is assumed a predefined palette, so colors are distributed uniformly in the whole data range + * When stops are passed, then rangeMin/rangeMax are used as reference for user defined limits: + * continuity is defined over rangeMin/rangeMax, not these stops values (rangeMin/rangeMax are computed from user's stop inputs) + */ +export function workoutColorForValue( + value: number | undefined, + params: CustomPaletteState, + minMax: { min: number; max: number } +) { + if (value == null) { + return; + } + const { colors, stops, range = 'percent', continuity = 'above', rangeMax, rangeMin } = params; + // ranges can be absolute numbers or percentages + // normalized the incoming value to the same format as range to make easier comparisons + const normalizedValue = getNormalizedValueByRange(value, params, minMax); + const dataRangeArguments = range === 'percent' ? [0, 100] : [minMax.min, minMax.max]; + const comparisonFn = (v: number, threshold: number) => v - threshold; + + // if steps are defined consider the specific rangeMax/Min as data boundaries + const maxRange = stops.length ? rangeMax : dataRangeArguments[1]; + const minRange = stops.length ? rangeMin : dataRangeArguments[0]; + + // in case of shorter rangers, extends the steps on the sides to cover the whole set + if (comparisonFn(normalizedValue, maxRange) > 0) { + if (continuity === 'above' || continuity === 'all') { + return colors[colors.length - 1]; + } + return; + } + if (comparisonFn(normalizedValue, minRange) < 0) { + if (continuity === 'below' || continuity === 'all') { + return colors[0]; + } + return; + } + + if (stops.length) { + return findColorsByStops(normalizedValue, comparisonFn, colors, stops); + } + + return findColorSegment( + normalizedValue, + comparisonFn, + colors, + dataRangeArguments[0], + dataRangeArguments[1] + ); +} diff --git a/src/plugins/charts/public/services/palettes/mock.ts b/src/plugins/charts/public/services/palettes/mock.ts index 1c112ec800c922..e94f47477ab11e 100644 --- a/src/plugins/charts/public/services/palettes/mock.ts +++ b/src/plugins/charts/public/services/palettes/mock.ts @@ -14,8 +14,8 @@ export const getPaletteRegistry = () => { const mockPalette1: jest.Mocked = { id: 'default', title: 'My Palette', - getColor: jest.fn((_: SeriesLayer[]) => 'black'), - getColors: jest.fn((num: number) => ['red', 'black']), + getCategoricalColor: jest.fn((_: SeriesLayer[]) => 'black'), + getCategoricalColors: jest.fn((num: number) => ['red', 'black']), toExpression: jest.fn(() => ({ type: 'expression', chain: [ @@ -33,8 +33,32 @@ export const getPaletteRegistry = () => { const mockPalette2: jest.Mocked = { id: 'mocked', title: 'Mocked Palette', - getColor: jest.fn((_: SeriesLayer[]) => 'blue'), - getColors: jest.fn((num: number) => ['blue', 'yellow']), + getCategoricalColor: jest.fn((_: SeriesLayer[]) => 'blue'), + getCategoricalColors: jest.fn((num: number) => ['blue', 'yellow']), + toExpression: jest.fn(() => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: ['mocked'], + }, + }, + ], + })), + }; + + const mockPalette3: jest.Mocked = { + id: 'custom', + title: 'Custom Mocked Palette', + getCategoricalColor: jest.fn((_: SeriesLayer[]) => 'blue'), + getCategoricalColors: jest.fn((num: number) => ['blue', 'yellow']), + getColorForValue: jest.fn( + (num: number | undefined, state: unknown, minMax: { min: number; max: number }) => + num == null || num < 1 ? undefined : 'blue' + ), + canDynamicColoring: true, toExpression: jest.fn(() => ({ type: 'expression', chain: [ @@ -50,8 +74,9 @@ export const getPaletteRegistry = () => { }; return { - get: (name: string) => (name !== 'default' ? mockPalette2 : mockPalette1), - getAll: () => [mockPalette1, mockPalette2], + get: (name: string) => + name === 'custom' ? mockPalette3 : name !== 'default' ? mockPalette2 : mockPalette1, + getAll: () => [mockPalette1, mockPalette2, mockPalette3], }; }; diff --git a/src/plugins/charts/public/services/palettes/palettes.test.tsx b/src/plugins/charts/public/services/palettes/palettes.test.tsx index 8f495df7f882af..8cb477b0e0838b 100644 --- a/src/plugins/charts/public/services/palettes/palettes.test.tsx +++ b/src/plugins/charts/public/services/palettes/palettes.test.tsx @@ -19,14 +19,14 @@ describe('palettes', () => { it('should return different colors based on behind text flag', () => { const palette = palettes.default; - const color1 = palette.getColor([ + const color1 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 0, totalSeriesAtDepth: 5, }, ]); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'abc', @@ -44,14 +44,14 @@ describe('palettes', () => { it('should return different colors based on rank at current series', () => { const palette = palettes.default; - const color1 = palette.getColor([ + const color1 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 0, totalSeriesAtDepth: 5, }, ]); - const color2 = palette.getColor([ + const color2 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 1, @@ -64,7 +64,7 @@ describe('palettes', () => { it('should return the same color for different positions on outer series layers', () => { const palette = palettes.default; - const color1 = palette.getColor([ + const color1 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 0, @@ -76,7 +76,7 @@ describe('palettes', () => { totalSeriesAtDepth: 2, }, ]); - const color2 = palette.getColor([ + const color2 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 0, @@ -96,7 +96,7 @@ describe('palettes', () => { it('should return different colors based on behind text flag', () => { const palette = palettes.default; - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'abc', @@ -108,7 +108,7 @@ describe('palettes', () => { syncColors: true, } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'abc', @@ -127,7 +127,7 @@ describe('palettes', () => { it('should return different colors for different keys', () => { const palette = palettes.default; - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'abc', @@ -139,7 +139,7 @@ describe('palettes', () => { syncColors: true, } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'def', @@ -157,7 +157,7 @@ describe('palettes', () => { it('should return the same color for the same key, irregardless of rank', () => { const palette = palettes.default; - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'hij', @@ -169,7 +169,7 @@ describe('palettes', () => { syncColors: true, } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'hij', @@ -187,7 +187,7 @@ describe('palettes', () => { it('should return the same color for different positions on outer series layers', () => { const palette = palettes.default; - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'klm', @@ -204,7 +204,7 @@ describe('palettes', () => { syncColors: true, } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'klm', @@ -227,7 +227,7 @@ describe('palettes', () => { it('should return the same index of the behind text palette for same key', () => { const palette = palettes.default; - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'klm', @@ -244,7 +244,7 @@ describe('palettes', () => { syncColors: true, } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'klm', @@ -273,15 +273,15 @@ describe('palettes', () => { const palette = palettes.warm; it('should use the whole gradient', () => { - const wholePalette = palette.getColors(10); - const color1 = palette.getColor([ + const wholePalette = palette.getCategoricalColors(10); + const color1 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 0, totalSeriesAtDepth: 10, }, ]); - const color2 = palette.getColor([ + const color2 = palette.getCategoricalColor([ { name: 'def', rankAtDepth: 9, @@ -304,7 +304,7 @@ describe('palettes', () => { describe('syncColors: false', () => { it('should not query legacy color service', () => { - palette.getColor( + palette.getCategoricalColor( [ { name: 'abc', @@ -323,7 +323,7 @@ describe('palettes', () => { it('should respect the advanced settings color mapping', () => { const configColorGetter = colorsServiceMock.mappedColors.getColorFromConfig as jest.Mock; configColorGetter.mockImplementation(() => 'blue'); - const result = palette.getColor( + const result = palette.getCategoricalColor( [ { name: 'abc', @@ -345,7 +345,7 @@ describe('palettes', () => { }); it('should return a color from the legacy palette based on position of first series', () => { - const result = palette.getColor( + const result = palette.getCategoricalColor( [ { name: 'abc', @@ -368,7 +368,7 @@ describe('palettes', () => { describe('syncColors: true', () => { it('should query legacy color service', () => { - palette.getColor( + palette.getCategoricalColor( [ { name: 'abc', @@ -387,7 +387,7 @@ describe('palettes', () => { it('should respect the advanced settings color mapping', () => { const configColorGetter = colorsServiceMock.mappedColors.getColorFromConfig as jest.Mock; configColorGetter.mockImplementation(() => 'blue'); - const result = palette.getColor( + const result = palette.getCategoricalColor( [ { name: 'abc', @@ -409,7 +409,7 @@ describe('palettes', () => { }); it('should always use root series', () => { - palette.getColor( + palette.getCategoricalColor( [ { name: 'abc', @@ -437,7 +437,7 @@ describe('palettes', () => { describe('custom palette', () => { const palette = palettes.custom; it('should return different colors based on rank at current series', () => { - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'abc', @@ -450,7 +450,7 @@ describe('palettes', () => { colors: ['#00ff00', '#000000'], } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'abc', @@ -467,7 +467,7 @@ describe('palettes', () => { }); it('should return the same color for different positions on outer series layers', () => { - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'abc', @@ -485,7 +485,7 @@ describe('palettes', () => { colors: ['#00ff00', '#000000'], } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'abc', @@ -507,7 +507,7 @@ describe('palettes', () => { }); it('should use passed in colors', () => { - const color = palette.getColor( + const color = palette.getCategoricalColor( [ { name: 'abc', @@ -523,5 +523,56 @@ describe('palettes', () => { ); expect(color).toEqual('#00ff00'); }); + + // just an integration test here. More in depth tests on the subject can be found on the helper file + it('should return a color for the given value with its domain', () => { + expect( + palette.getColorForValue!( + 0, + { colors: ['red', 'green', 'blue'], stops: [], gradient: false }, + { min: 0, max: 100 } + ) + ).toBe('red'); + }); + + it('should return a color for the given value with its domain based on custom stops', () => { + expect( + palette.getColorForValue!( + 60, + { + colors: ['red', 'green', 'blue'], + stops: [10, 50, 100], + range: 'percent', + gradient: false, + rangeMin: 0, + rangeMax: 100, + }, + { min: 0, max: 100 } + ) + ).toBe('blue'); + }); + + // just make sure to not have broken anything + it('should work with only legacy arguments, filling with default values the new ones', () => { + expect(palette.toExpression({ colors: [], gradient: false })).toEqual({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'palette', + arguments: { + color: [], + gradient: [false], + reverse: [false], + continuity: ['above'], + stop: [], + range: ['percent'], + rangeMax: [], + rangeMin: [], + }, + }, + ], + }); + }); }); }); diff --git a/src/plugins/charts/public/services/palettes/palettes.tsx b/src/plugins/charts/public/services/palettes/palettes.tsx index b11d598c1c1cbc..65e3f9a84203dd 100644 --- a/src/plugins/charts/public/services/palettes/palettes.tsx +++ b/src/plugins/charts/public/services/palettes/palettes.tsx @@ -30,6 +30,7 @@ import { lightenColor } from './lighten_color'; import { ChartColorConfiguration, PaletteDefinition, SeriesLayer } from './types'; import { LegacyColorsService } from '../legacy_colors'; import { MappedColors } from '../mapped_colors'; +import { workoutColorForValue } from './helpers'; function buildRoundRobinCategoricalWithMappedColors(): Omit { const colors = euiPaletteColorBlind({ rotations: 2 }); @@ -64,8 +65,8 @@ function buildRoundRobinCategoricalWithMappedColors(): Omit euiPaletteColorBlind(), + getCategoricalColor: getColor, + getCategoricalColors: () => euiPaletteColorBlind(), toExpression: () => ({ type: 'expression', chain: [ @@ -102,8 +103,9 @@ function buildGradient( } return { id, - getColor, - getColors: colors, + getCategoricalColor: getColor, + getCategoricalColors: colors, + canDynamicColoring: true, toExpression: () => ({ type: 'expression', chain: [ @@ -141,8 +143,8 @@ function buildSyncedKibanaPalette( } return { id: 'kibana_palette', - getColor, - getColors: () => colors.seedColors.slice(0, 10), + getCategoricalColor: getColor, + getCategoricalColors: () => colors.seedColors.slice(0, 10), toExpression: () => ({ type: 'expression', chain: [ @@ -161,7 +163,24 @@ function buildSyncedKibanaPalette( function buildCustomPalette(): PaletteDefinition { return { id: 'custom', - getColor: ( + getColorForValue: ( + value, + params: { + colors: string[]; + range: 'number' | 'percent'; + continuity: 'above' | 'below' | 'none' | 'all'; + gradient: boolean; + /** Stops values mark where colors end (non-inclusive value) */ + stops: number[]; + /** Important: specify rangeMin/rangeMax if custom stops are defined! */ + rangeMax: number; + rangeMin: number; + }, + dataBounds + ) => { + return workoutColorForValue(value, params, dataBounds); + }, + getCategoricalColor: ( series: SeriesLayer[], chartConfiguration: ChartColorConfiguration = { behindText: false }, { colors, gradient }: { colors: string[]; gradient: boolean } @@ -179,10 +198,48 @@ function buildCustomPalette(): PaletteDefinition { }, internal: true, title: i18n.translate('charts.palettes.customLabel', { defaultMessage: 'Custom' }), - getColors: (size: number, { colors, gradient }: { colors: string[]; gradient: boolean }) => { + getCategoricalColors: ( + size: number, + { + colors, + gradient, + stepped, + stops, + }: { colors: string[]; gradient: boolean; stepped: boolean; stops: number[] } = { + colors: [], + gradient: false, + stepped: false, + stops: [], + } + ) => { + if (stepped) { + const range = stops[stops.length - 1] - stops[0]; + const offset = stops[0]; + const finalStops = [...stops.map((stop) => (stop - offset) / range)]; + return chroma.scale(colors).domain(finalStops).colors(size); + } return gradient ? chroma.scale(colors).colors(size) : colors; }, - toExpression: ({ colors, gradient }: { colors: string[]; gradient: boolean }) => ({ + canDynamicColoring: false, + toExpression: ({ + colors, + gradient, + stops = [], + rangeMax, + rangeMin, + rangeType = 'percent', + continuity = 'above', + reverse = false, + }: { + colors: string[]; + gradient: boolean; + stops: number[]; + rangeMax?: number; + rangeMin?: number; + rangeType: 'percent' | 'number'; + continuity?: 'all' | 'none' | 'above' | 'below'; + reverse?: boolean; + }) => ({ type: 'expression', chain: [ { @@ -191,6 +248,12 @@ function buildCustomPalette(): PaletteDefinition { arguments: { color: colors, gradient: [gradient], + reverse: [reverse], + continuity: [continuity], + stop: stops, + range: [rangeType], + rangeMax: rangeMax == null ? [] : [rangeMax], + rangeMin: rangeMin == null ? [] : [rangeMin], }, }, ], diff --git a/src/plugins/charts/public/services/palettes/types.ts b/src/plugins/charts/public/services/palettes/types.ts index 3d2a6b032f63e9..6f13f621783640 100644 --- a/src/plugins/charts/public/services/palettes/types.ts +++ b/src/plugins/charts/public/services/palettes/types.ts @@ -79,22 +79,12 @@ export interface PaletteDefinition { * @param state The internal state of the palette */ toExpression: (state?: T) => Ast; - /** - * Renders the UI for editing the internal state of the palette. - * Not each palette has to feature an internal state, so this is an optional property. - * @param domElement The dom element to the render the editor UI into - * @param props Current state and state setter to issue updates - */ - renderEditor?: ( - domElement: Element, - props: { state?: T; setState: (updater: (oldState: T) => T) => void } - ) => void; /** * Color a series according to the internal rules of the palette. * @param series The current series along with its ancestors. * @param state The internal state of the palette */ - getColor: ( + getCategoricalColor: ( series: SeriesLayer[], chartConfiguration?: ChartColorConfiguration, state?: T @@ -103,7 +93,20 @@ export interface PaletteDefinition { * Get a spectrum of colors of the current palette. * This can be used if the chart wants to control color assignment locally. */ - getColors: (size: number, state?: T) => string[]; + getCategoricalColors: (size: number, state?: T) => string[]; + /** + * Define whether a palette supports dynamic coloring (i.e. gradient colors mapped to number values) + */ + canDynamicColoring?: boolean; + /** + * Get the assigned color for the given value based on its data domain and state settings. + * This can be used for dynamic coloring based on uniform color distribution or custom stops. + */ + getColorForValue?: ( + value: number | undefined, + state: T, + { min, max }: { min: number; max: number } + ) => string | undefined; } export interface PaletteRegistry { diff --git a/src/plugins/vis_default_editor/public/components/controls/palette_picker.tsx b/src/plugins/vis_default_editor/public/components/controls/palette_picker.tsx index b09a806e8fc253..9249edef8af921 100644 --- a/src/plugins/vis_default_editor/public/components/controls/palette_picker.tsx +++ b/src/plugins/vis_default_editor/public/components/controls/palette_picker.tsx @@ -39,12 +39,12 @@ export function PalettePicker({ palettes={palettes .getAll() .filter(({ internal }) => !internal) - .map(({ id, title, getColors }) => { + .map(({ id, title, getCategoricalColors }) => { return { value: id, title, type: 'fixed', - palette: getColors( + palette: getCategoricalColors( 10, id === activePalette?.name ? activePalette?.params : undefined ), diff --git a/src/plugins/vis_type_timeseries/public/application/components/palette_picker.tsx b/src/plugins/vis_type_timeseries/public/application/components/palette_picker.tsx index 20c0b40bb2e542..749d6ca62bfa93 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/palette_picker.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/palette_picker.tsx @@ -33,12 +33,12 @@ export function PalettePicker({ activePalette, palettes, setPalette, color }: Pa ...palettes .getAll() .filter(({ internal }) => !internal) - .map(({ id, title, getColors }) => { + .map(({ id, title, getCategoricalColors }) => { return { value: id, title, type: 'fixed' as const, - palette: getColors(10), + palette: getCategoricalColors(10), }; }), { @@ -49,7 +49,7 @@ export function PalettePicker({ activePalette, palettes, setPalette, color }: Pa type: 'fixed', palette: palettes .get('custom') - .getColors(10, { colors: [color, finalGradientColor], gradient: true }), + .getCategoricalColors(10, { colors: [color, finalGradientColor], gradient: true }), }, { value: PALETTES.RAINBOW, @@ -59,7 +59,7 @@ export function PalettePicker({ activePalette, palettes, setPalette, color }: Pa type: 'fixed', palette: palettes .get('custom') - .getColors(10, { colors: rainbowColors.slice(0, 10), gradient: false }), + .getCategoricalColors(10, { colors: rainbowColors.slice(0, 10), gradient: false }), }, ]} onChange={(newPalette) => { diff --git a/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts b/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts index adcf1f3ad63cdb..028ce3d028997c 100644 --- a/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts +++ b/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts @@ -58,7 +58,7 @@ export const getSplitByTermsColor = ({ } : seriesPalette.params; - const outputColor = palettesRegistry?.get(paletteName || 'default').getColor( + const outputColor = palettesRegistry?.get(paletteName || 'default').getCategoricalColor( [ { name: seriesName || emptyLabel, diff --git a/src/plugins/vis_type_xy/public/vis_component.tsx b/src/plugins/vis_type_xy/public/vis_component.tsx index 5da5ffcc637c6c..dd88822f7f0f36 100644 --- a/src/plugins/vis_type_xy/public/vis_component.tsx +++ b/src/plugins/vis_type_xy/public/vis_component.tsx @@ -245,7 +245,7 @@ const VisComponent = (props: VisComponentProps) => { if (Object.keys(overwriteColors).includes(seriesName)) { return overwriteColors[seriesName]; } - const outputColor = palettesRegistry?.get(visParams.palette.name).getColor( + const outputColor = palettesRegistry?.get(visParams.palette.name).getCategoricalColor( [ { name: seriesName, diff --git a/x-pack/plugins/canvas/public/functions/pie.test.js b/x-pack/plugins/canvas/public/functions/pie.test.js index 915d8525079dbf..b1c17463408928 100644 --- a/x-pack/plugins/canvas/public/functions/pie.test.js +++ b/x-pack/plugins/canvas/public/functions/pie.test.js @@ -18,7 +18,7 @@ describe('pie', () => { const fn = functionWrapper( pieFunctionFactory({ get: () => ({ - getColors: () => ['red', 'black'], + getCategoricalColors: () => ['red', 'black'], }), }) ); @@ -59,7 +59,7 @@ describe('pie', () => { const mockedFn = functionWrapper( pieFunctionFactory({ get: () => ({ - getColors: mockedColors, + getCategoricalColors: mockedColors, }), }) ); diff --git a/x-pack/plugins/canvas/public/functions/pie.ts b/x-pack/plugins/canvas/public/functions/pie.ts index 0840667302ebef..a91dc16b770c9f 100644 --- a/x-pack/plugins/canvas/public/functions/pie.ts +++ b/x-pack/plugins/canvas/public/functions/pie.ts @@ -173,7 +173,7 @@ export function pieFunctionFactory( canvas: false, colors: paletteService .get(palette.name || 'custom') - .getColors(data.length, palette.params), + .getCategoricalColors(data.length, palette.params), legend: getLegendConfig(legend, data.length), grid: { show: false, diff --git a/x-pack/plugins/canvas/public/functions/plot.test.js b/x-pack/plugins/canvas/public/functions/plot.test.js index 849752d2c984ba..5ed858961d7980 100644 --- a/x-pack/plugins/canvas/public/functions/plot.test.js +++ b/x-pack/plugins/canvas/public/functions/plot.test.js @@ -21,7 +21,7 @@ describe('plot', () => { const fn = functionWrapper( plotFunctionFactory({ get: () => ({ - getColors: () => ['red', 'black'], + getCategoricalColors: () => ['red', 'black'], }), }) ); @@ -121,7 +121,7 @@ describe('plot', () => { const mockedFn = functionWrapper( plotFunctionFactory({ get: () => ({ - getColors: mockedColors, + getCategoricalColors: mockedColors, }), }) ); diff --git a/x-pack/plugins/canvas/public/functions/plot/index.ts b/x-pack/plugins/canvas/public/functions/plot/index.ts index c0c73c3a21bc6c..477c7041901461 100644 --- a/x-pack/plugins/canvas/public/functions/plot/index.ts +++ b/x-pack/plugins/canvas/public/functions/plot/index.ts @@ -144,7 +144,7 @@ export function plotFunctionFactory( canvas: false, colors: paletteService .get(args.palette.name || 'custom') - .getColors(data.length, args.palette.params), + .getCategoricalColors(data.length, args.palette.params), legend: getLegendConfig(args.legend, data.length), grid: gridConfig, xaxis: getFlotAxisConfig('x', args.xaxis, { diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap index afc69c2e8861f9..a4be46f61990b6 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap +++ b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap @@ -13,6 +13,13 @@ exports[`DatatableComponent it renders actions column when there are row actions "b": "left", "c": "right", }, + "getColorForValue": [MockFunction], + "minMaxByColumnId": Object { + "c": Object { + "max": 3, + "min": 3, + }, + }, "rowHasRowClickTriggerActions": Array [ true, true, @@ -244,6 +251,13 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "b": "left", "c": "right", }, + "getColorForValue": [MockFunction], + "minMaxByColumnId": Object { + "c": Object { + "max": 3, + "min": 3, + }, + }, "rowHasRowClickTriggerActions": undefined, "table": Object { "columns": Array [ @@ -462,6 +476,13 @@ exports[`DatatableComponent it should not render actions on header when it is in "b": "left", "c": "right", }, + "getColorForValue": [MockFunction], + "minMaxByColumnId": Object { + "c": Object { + "max": 3, + "min": 3, + }, + }, "rowHasRowClickTriggerActions": Array [ false, false, diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx index 9bc982ebd9944b..67255dc8a953ea 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx @@ -11,6 +11,12 @@ import { DataContext } from './table_basic'; import { createGridCell } from './cell_value'; import { FieldFormat } from 'src/plugins/data/public'; import { Datatable } from 'src/plugins/expressions/public'; +import { IUiSettingsClient } from 'kibana/public'; +import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; +import { Args, ColumnConfigArg } from '../expression'; +import { DataContextType } from './types'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; describe('datatable cell renderer', () => { const table: Datatable = { @@ -30,7 +36,9 @@ describe('datatable cell renderer', () => { { a: { convert: (x) => `formatted ${x}` } as FieldFormat, }, - DataContext + { columns: [], sortingColumnId: '', sortingDirection: 'none' }, + DataContext, + ({ get: jest.fn() } as unknown) as IUiSettingsClient ); it('renders formatted value', () => { @@ -78,4 +86,111 @@ describe('datatable cell renderer', () => { ); expect(cell.find('.lnsTableCell').prop('className')).toContain('--right'); }); + + describe('dynamic coloring', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + const customPalette = paletteRegistry.get('custom'); + + function getCellRenderer(columnConfig: Args) { + return createGridCell( + { + a: { convert: (x) => `formatted ${x}` } as FieldFormat, + }, + columnConfig, + DataContext, + ({ get: jest.fn() } as unknown) as IUiSettingsClient + ); + } + function getColumnConfiguration(): Args { + return { + title: 'myData', + columns: [ + { + columnId: 'a', + colorMode: 'none', + palette: { + type: 'palette', + name: 'custom', + params: { + colors: ['#aaa', '#bbb', '#ccc', '#ddd', '#eee'], + gradient: false, + stops: [20, 40, 60, 80, 100], + range: 'percent', + rangeMin: 0, + rangeMax: 100, + }, + }, + type: 'lens_datatable_column', + } as ColumnConfigArg, + ], + sortingColumnId: '', + sortingDirection: 'none', + }; + } + + function flushEffect(component: ReactWrapper) { + return act(async () => { + await component; + await new Promise((r) => setImmediate(r)); + component.update(); + }); + } + + async function renderCellComponent(columnConfig: Args, context: Partial = {}) { + const CellRendererWithPalette = getCellRenderer(columnConfig); + const setCellProps = jest.fn(); + + const cell = mountWithIntl( + 123 */ } }, + getColorForValue: customPalette.getColorForValue, + ...context, + }} + > + + + ); + + await flushEffect(cell); + + return { setCellProps, cell }; + } + + it('ignores coloring when colorMode is set to "none"', async () => { + const { setCellProps } = await renderCellComponent(getColumnConfiguration()); + + expect(setCellProps).not.toHaveBeenCalled(); + }); + + it('should set the coloring of the cell when enabled', async () => { + const columnConfig = getColumnConfiguration(); + columnConfig.columns[0].colorMode = 'cell'; + + const { setCellProps } = await renderCellComponent(columnConfig, {}); + + expect(setCellProps).toHaveBeenCalledWith({ + style: expect.objectContaining({ backgroundColor: 'blue' }), + }); + }); + + it('should set the coloring of the text when enabled', async () => { + const columnConfig = getColumnConfiguration(); + columnConfig.columns[0].colorMode = 'text'; + + const { setCellProps } = await renderCellComponent(columnConfig, {}); + + expect(setCellProps).toHaveBeenCalledWith({ + style: expect.objectContaining({ color: 'blue' }), + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx index 2261dd06b532ba..a6c50f00cb77fd 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx @@ -5,30 +5,74 @@ * 2.0. */ -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import { IUiSettingsClient } from 'kibana/public'; import type { FormatFactory } from '../../types'; import type { DataContextType } from './types'; +import { ColumnConfig } from './table_basic'; +import { getContrastColor } from '../../shared_components/coloring/utils'; +import { getOriginalId } from '../transpose_helpers'; export const createGridCell = ( formatters: Record>, - DataContext: React.Context -) => ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { - const { table, alignments } = useContext(DataContext); - const rowValue = table?.rows[rowIndex][columnId]; - const content = formatters[columnId]?.convert(rowValue, 'html'); - const currentAlignment = alignments && alignments[columnId]; - const alignmentClassName = `lnsTableCell--${currentAlignment}`; + columnConfig: ColumnConfig, + DataContext: React.Context, + uiSettings: IUiSettingsClient +) => { + // Changing theme requires a full reload of the page, so we can cache here + const IS_DARK_THEME = uiSettings.get('theme:darkMode'); + return ({ rowIndex, columnId, setCellProps }: EuiDataGridCellValueElementProps) => { + const { table, alignments, minMaxByColumnId, getColorForValue } = useContext(DataContext); + const rowValue = table?.rows[rowIndex][columnId]; + const content = formatters[columnId]?.convert(rowValue, 'html'); + const currentAlignment = alignments && alignments[columnId]; + const alignmentClassName = `lnsTableCell--${currentAlignment}`; - return ( -
- ); + const { colorMode, palette } = + columnConfig.columns.find(({ columnId: id }) => id === columnId) || {}; + + useEffect(() => { + const originalId = getOriginalId(columnId); + if (minMaxByColumnId?.[originalId]) { + if (colorMode !== 'none' && palette?.params && getColorForValue) { + // workout the bucket the value belongs to + const color = getColorForValue(rowValue, palette.params, minMaxByColumnId[originalId]); + if (color) { + const style = { [colorMode === 'cell' ? 'backgroundColor' : 'color']: color }; + if (colorMode === 'cell' && color) { + style.color = getContrastColor(color, IS_DARK_THEME); + } + setCellProps({ + style, + }); + } + } + } + // make sure to clean it up when something change + // this avoids cell's styling to stick forever + return () => { + if (minMaxByColumnId?.[originalId]) { + setCellProps({ + style: { + backgroundColor: undefined, + color: undefined, + }, + }); + } + }; + }, [rowValue, columnId, setCellProps, colorMode, palette, minMaxByColumnId, getColorForValue]); + + return ( +
+ ); + }; }; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.scss b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.scss new file mode 100644 index 00000000000000..504adb05e57d7b --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.scss @@ -0,0 +1,7 @@ +.lnsDynamicColoringRow { + align-items: center; +} + +.lnsDynamicColoringClickable { + cursor: pointer; +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx index e0d31a3ed02012..88948e9a7615b8 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx @@ -12,12 +12,18 @@ import { DatatableVisualizationState } from '../visualization'; import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks'; import { mountWithIntl } from '@kbn/test/jest'; import { TableDimensionEditor } from './dimension_editor'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { PaletteRegistry } from 'src/plugins/charts/public'; +import { PalettePanelContainer } from './palette_panel_container'; +import { act } from 'react-dom/test-utils'; describe('data table dimension editor', () => { let frame: FramePublicAPI; let state: DatatableVisualizationState; let setState: (newState: DatatableVisualizationState) => void; - let props: VisualizationDimensionEditorProps; + let props: VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; + }; function testState(): DatatableVisualizationState { return { @@ -59,6 +65,8 @@ describe('data table dimension editor', () => { layerId: 'first', state, setState, + paletteService: chartPluginMock.createPaletteRegistry(), + panelRef: React.createRef(), }; }); @@ -72,17 +80,23 @@ describe('data table dimension editor', () => { it('should render default alignment for number', () => { frame.activeData!.first.columns[0].meta.type = 'number'; const instance = mountWithIntl(); - expect(instance.find(EuiButtonGroup).prop('idSelected')).toEqual( - expect.stringContaining('right') - ); + expect( + instance + .find('[data-test-subj="lnsDatatable_alignment_groups"]') + .find(EuiButtonGroup) + .prop('idSelected') + ).toEqual(expect.stringContaining('right')); }); it('should render specific alignment', () => { state.columns[0].alignment = 'center'; const instance = mountWithIntl(); - expect(instance.find(EuiButtonGroup).prop('idSelected')).toEqual( - expect.stringContaining('center') - ); + expect( + instance + .find('[data-test-subj="lnsDatatable_alignment_groups"]') + .find(EuiButtonGroup) + .prop('idSelected') + ).toEqual(expect.stringContaining('center')); }); it('should set state for the right column', () => { @@ -95,7 +109,10 @@ describe('data table dimension editor', () => { }, ]; const instance = mountWithIntl(); - instance.find(EuiButtonGroup).prop('onChange')('center'); + instance + .find('[data-test-subj="lnsDatatable_alignment_groups"]') + .find(EuiButtonGroup) + .prop('onChange')('center'); expect(setState).toHaveBeenCalledWith({ ...state, columns: [ @@ -109,4 +126,90 @@ describe('data table dimension editor', () => { ], }); }); + + it('should not show the dynamic coloring option for non numeric columns', () => { + const instance = mountWithIntl(); + expect(instance.find('[data-test-subj="lnsDatatable_dynamicColoring_groups"]').exists()).toBe( + false + ); + expect(instance.find('[data-test-subj="lnsDatatable_dynamicColoring_palette"]').exists()).toBe( + false + ); + }); + + it('should set the dynamic coloring default to "none"', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + const instance = mountWithIntl(); + expect( + instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_groups"]') + .find(EuiButtonGroup) + .prop('idSelected') + ).toEqual(expect.stringContaining('none')); + + expect(instance.find('[data-test-subj="lnsDatatable_dynamicColoring_palette"]').exists()).toBe( + false + ); + }); + + it('should show the dynamic palette display ony when colorMode is different from "none"', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + state.columns[0].colorMode = 'text'; + const instance = mountWithIntl(); + expect( + instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_groups"]') + .find(EuiButtonGroup) + .prop('idSelected') + ).toEqual(expect.stringContaining('text')); + + expect(instance.find('[data-test-subj="lnsDatatable_dynamicColoring_palette"]').exists()).toBe( + true + ); + }); + + it('should set the coloring mode to the right column', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + state.columns = [ + { + columnId: 'foo', + }, + { + columnId: 'bar', + }, + ]; + const instance = mountWithIntl(); + instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_groups"]') + .find(EuiButtonGroup) + .prop('onChange')('cell'); + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: [ + { + columnId: 'foo', + colorMode: 'cell', + palette: expect.objectContaining({ type: 'palette' }), + }, + { + columnId: 'bar', + }, + ], + }); + }); + + it('should open the palette panel when "Settings" link is clicked in the palette input', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + state.columns[0].colorMode = 'cell'; + const instance = mountWithIntl(); + + act(() => + (instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_trigger"]') + .first() + .prop('onClick') as () => void)?.() + ); + + expect(instance.find(PalettePanelContainer).exists()).toBe(true); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx index a750744811790f..76c47a9c743c51 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -5,36 +5,91 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiSwitch, EuiButtonGroup, htmlIdGenerator } from '@elastic/eui'; +import { + EuiFormRow, + EuiSwitch, + EuiButtonGroup, + htmlIdGenerator, + EuiColorPaletteDisplay, + EuiFlexItem, + EuiFlexGroup, + EuiButtonEmpty, +} from '@elastic/eui'; +import { PaletteRegistry } from 'src/plugins/charts/public'; import { VisualizationDimensionEditorProps } from '../../types'; import { DatatableVisualizationState } from '../visualization'; import { getOriginalId } from '../transpose_helpers'; +import { + CustomizablePalette, + applyPaletteParams, + defaultPaletteParams, + FIXED_PROGRESSION, + getStopsForFixedMode, +} from '../../shared_components/'; +import { PalettePanelContainer } from './palette_panel_container'; +import { findMinMaxByColumnId } from './shared_utils'; +import './dimension_editor.scss'; const idPrefix = htmlIdGenerator()(); +type ColumnType = DatatableVisualizationState['columns'][number]; + +function updateColumnWith( + state: DatatableVisualizationState, + columnId: string, + newColumnProps: Partial +) { + return state.columns.map((currentColumn) => { + if (currentColumn.columnId === columnId) { + return { ...currentColumn, ...newColumnProps }; + } else { + return currentColumn; + } + }); +} + export function TableDimensionEditor( - props: VisualizationDimensionEditorProps + props: VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; + } ) { const { state, setState, frame, accessor } = props; const column = state.columns.find(({ columnId }) => accessor === columnId); + const [isPaletteOpen, setIsPaletteOpen] = useState(false); if (!column) return null; if (column.isTransposed) return null; + const currentData = frame.activeData?.[state.layerId]; + // either read config state or use same logic as chart itself - const currentAlignment = - column?.alignment || - (frame.activeData && - frame.activeData[state.layerId]?.columns.find( - (col) => col.id === accessor || getOriginalId(col.id) === accessor - )?.meta.type === 'number' - ? 'right' - : 'left'); + const isNumericField = + currentData?.columns.find((col) => col.id === accessor || getOriginalId(col.id) === accessor) + ?.meta.type === 'number'; + + const currentAlignment = column?.alignment || (isNumericField ? 'right' : 'left'); + const currentColorMode = column?.colorMode || 'none'; + const hasDynamicColoring = currentColorMode !== 'none'; const visibleColumnsCount = state.columns.filter((c) => !c.hidden).length; + const hasTransposedColumn = state.columns.some(({ isTransposed }) => isTransposed); + const columnsToCheck = hasTransposedColumn + ? currentData?.columns.filter(({ id }) => getOriginalId(id) === accessor).map(({ id }) => id) || + [] + : [accessor]; + const minMaxByColumnId = findMinMaxByColumnId(columnsToCheck, currentData); + const currentMinMax = minMaxByColumnId[accessor]; + + const activePalette = column?.palette || { + type: 'palette', + name: defaultPaletteParams.name, + }; + // need to tell the helper that the colorStops are required to display + const displayStops = applyPaletteParams(props.paletteService, activePalette, currentMinMax); + return ( <> { - const newMode = id.replace(idPrefix, '') as 'left' | 'right' | 'center'; - const newColumns = state.columns.map((currentColumn) => { - if (currentColumn.columnId === accessor) { - return { - ...currentColumn, - alignment: newMode, - }; - } else { - return currentColumn; - } + const newMode = id.replace(idPrefix, '') as ColumnType['alignment']; + setState({ + ...state, + columns: updateColumnWith(state, accessor, { alignment: newMode }), }); - setState({ ...state, columns: newColumns }); }} /> @@ -127,6 +175,135 @@ export function TableDimensionEditor( /> )} + {isNumericField && ( + <> + + { + const newMode = id.replace(idPrefix, '') as ColumnType['colorMode']; + const params: Partial = { + colorMode: newMode, + }; + if (!column?.palette && newMode !== 'none') { + params.palette = { + ...activePalette, + params: { + ...activePalette.params, + // that's ok, at first open we're going to throw them away and recompute + stops: displayStops, + }, + }; + } + // clear up when switching to no coloring + if (column?.palette && newMode === 'none') { + params.palette = undefined; + } + setState({ + ...state, + columns: updateColumnWith(state, accessor, params), + }); + }} + /> + + {hasDynamicColoring && ( + + + + { + setIsPaletteOpen(!isPaletteOpen); + }} + /> + + + { + setIsPaletteOpen(!isPaletteOpen); + }} + size="xs" + flush="both" + > + {i18n.translate('xpack.lens.paletteTableGradient.customize', { + defaultMessage: 'Edit', + })} + + setIsPaletteOpen(!isPaletteOpen)} + > + { + setState({ + ...state, + columns: updateColumnWith(state, accessor, { palette: newPalette }), + }); + }} + /> + + + + + )} + + )} ); } diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.scss b/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.scss new file mode 100644 index 00000000000000..db14d064d1881e --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.scss @@ -0,0 +1,53 @@ +@import '@elastic/eui/src/components/flyout/variables'; +@import '@elastic/eui/src/components/flyout/mixins'; + +.lnsPalettePanelContainer { + // Use the EuiFlyout style + @include euiFlyout; + // But with custom positioning to keep it within the sidebar contents + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; + animation: euiFlyout $euiAnimSpeedNormal $euiAnimSlightResistance; + // making just a bit higher than the dimension flyout to stack on top of it + z-index: $euiZLevel3 + 1 +} + +.lnsPalettePanelContainer__footer { + padding: $euiSizeS; +} + +.lnsPalettePanelContainer__header { + padding: $euiSizeS $euiSizeXS; +} + +.lnsPalettePanelContainer__headerTitle { + padding: $euiSizeS $euiSizeXS; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +.lnsPalettePanelContainer__headerLink { + &:focus-within { + background-color: transparentize($euiColorVis1, .9); + + .lnsPalettePanelContainer__headerTitle { + text-decoration: underline; + } + } +} + +.lnsPalettePanelContainer__backIcon { + &:hover { + transform: none !important; // sass-lint:disable-line no-important + } + + &:focus { + background-color: transparent; + } +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.tsx new file mode 100644 index 00000000000000..1371fbe73ef845 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.tsx @@ -0,0 +1,112 @@ +/* + * 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 './palette_panel_container.scss'; + +import React, { useState, useEffect, MutableRefObject } from 'react'; +import { + EuiFlyoutHeader, + EuiFlyoutFooter, + EuiTitle, + EuiButtonIcon, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFocusTrap, + EuiOutsideClickDetector, + EuiPortal, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +export function PalettePanelContainer({ + isOpen, + handleClose, + children, + siblingRef, +}: { + isOpen: boolean; + handleClose: () => void; + children: React.ReactElement | React.ReactElement[]; + siblingRef: MutableRefObject; +}) { + const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false); + + const closeFlyout = () => { + handleClose(); + setFocusTrapIsEnabled(false); + }; + + useEffect(() => { + if (isOpen) { + // without setTimeout here the flyout pushes content when animating + setTimeout(() => { + setFocusTrapIsEnabled(true); + }, 255); + } + }, [isOpen]); + + return isOpen && siblingRef.current ? ( + + + +
+ + + + + + + +

+ + {i18n.translate('xpack.lens.table.palettePanelTitle', { + defaultMessage: 'Edit color', + })} + +

+
+
+
+
+ + {children} + + + + {i18n.translate('xpack.lens.table.palettePanelContainer.back', { + defaultMessage: 'Back', + })} + + +
+
+
+
+ ) : null; +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/shared_utils.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/shared_utils.tsx new file mode 100644 index 00000000000000..92a949e65c67ea --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/shared_utils.tsx @@ -0,0 +1,36 @@ +/* + * 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 { Datatable } from 'src/plugins/expressions'; +import { getOriginalId } from '../transpose_helpers'; + +export const findMinMaxByColumnId = (columnIds: string[], table: Datatable | undefined) => { + const minMax: Record = {}; + + if (table != null) { + for (const columnId of columnIds) { + const originalId = getOriginalId(columnId); + minMax[originalId] = minMax[originalId] || { max: -Infinity, min: Infinity }; + table.rows.forEach((row) => { + const rowValue = row[columnId]; + if (rowValue != null) { + if (minMax[originalId].min > rowValue) { + minMax[originalId].min = rowValue; + } + if (minMax[originalId].max < rowValue) { + minMax[originalId].max = rowValue; + } + } + }); + // what happens when there's no data in the table? Fallback to a percent range + if (minMax[originalId].max === -Infinity) { + minMax[originalId] = { max: 100, min: 0, fallback: true }; + } + } + } + return minMax; +}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx index 22577e8ef5fd31..509969c2b71ec0 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -15,6 +15,8 @@ import { LensIconChartDatatable } from '../../assets/chart_datatable'; import { DataContext, DatatableComponent } from './table_basic'; import { LensMultiTable } from '../../types'; import { DatatableProps } from '../expression'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { IUiSettingsClient } from 'kibana/public'; function sampleArgs() { const indexPatternId = 'indexPatternId'; @@ -99,6 +101,8 @@ describe('DatatableComponent', () => { formatFactory={(x) => x as IFieldFormat} dispatchEvent={onDispatchEvent} getType={jest.fn()} + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} renderMode="edit" /> ) @@ -118,6 +122,8 @@ describe('DatatableComponent', () => { getType={jest.fn()} rowHasRowClickTriggerActions={[true, true, true]} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ) ).toMatchSnapshot(); @@ -136,6 +142,8 @@ describe('DatatableComponent', () => { getType={jest.fn()} rowHasRowClickTriggerActions={[false, false, false]} renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ) ).toMatchSnapshot(); @@ -158,6 +166,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -199,6 +209,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -279,6 +291,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -325,6 +339,8 @@ describe('DatatableComponent', () => { type === 'count' ? ({ type: 'metrics' } as IAggType) : ({ type: 'buckets' } as IAggType) )} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDatatable); @@ -345,6 +361,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -393,6 +411,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -421,6 +441,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -447,6 +469,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -471,6 +495,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); // mnake a copy of the data, changing only the name of the first column @@ -483,4 +509,34 @@ describe('DatatableComponent', () => { 'new a' ); }); + + test('it does compute minMax for each numeric column', () => { + const { data, args } = sampleArgs(); + + const wrapper = shallow( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} + /> + ); + + expect(wrapper.find(DataContext.Provider).prop('value').minMaxByColumnId).toEqual({ + c: { min: 3, max: 3 }, + }); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index 24cde07cebaa0e..e6fcf3f321f7f3 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -18,6 +18,7 @@ import { EuiDataGridSorting, EuiDataGridStyle, } from '@elastic/eui'; +import { CustomPaletteState, PaletteOutput } from 'src/plugins/charts/common'; import { FormatFactory, LensFilterEvent, LensTableRowContextMenuEvent } from '../../types'; import { VisualizationContainer } from '../../visualization_container'; import { EmptyPlaceholder } from '../../shared_components'; @@ -40,6 +41,8 @@ import { createGridSortingConfig, createTransposeColumnFilterHandler, } from './table_actions'; +import { findMinMaxByColumnId } from './shared_utils'; +import { CUSTOM_PALETTE } from '../../shared_components/coloring/constants'; export const DataContext = React.createContext({}); @@ -50,8 +53,9 @@ const gridStyle: EuiDataGridStyle = { export interface ColumnConfig { columns: Array< - ColumnState & { + Omit & { type: 'lens_datatable_column'; + palette?: PaletteOutput; } >; sortingColumnId: string | undefined; @@ -203,20 +207,34 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ] ); + const isNumericMap: Record = useMemo(() => { + const numericMap: Record = {}; + for (const column of firstLocalTable.columns) { + numericMap[column.id] = column.meta.type === 'number'; + } + return numericMap; + }, [firstLocalTable]); + const alignments: Record = useMemo(() => { const alignmentMap: Record = {}; columnConfig.columns.forEach((column) => { if (column.alignment) { alignmentMap[column.columnId] = column.alignment; } else { - const isNumeric = - firstLocalTable.columns.find((dataColumn) => dataColumn.id === column.columnId)?.meta - .type === 'number'; - alignmentMap[column.columnId] = isNumeric ? 'right' : 'left'; + alignmentMap[column.columnId] = isNumericMap[column.columnId] ? 'right' : 'left'; } }); return alignmentMap; - }, [firstLocalTable, columnConfig]); + }, [columnConfig, isNumericMap]); + + const minMaxByColumnId: Record = useMemo(() => { + return findMinMaxByColumnId( + columnConfig.columns + .filter(({ columnId }) => isNumericMap[columnId]) + .map(({ columnId }) => columnId), + firstTable + ); + }, [firstTable, isNumericMap, columnConfig]); const trailingControlColumns: EuiDataGridControlColumn[] = useMemo(() => { if (!hasAtLeastOneRowClickAction || !onRowContextMenuClick) { @@ -254,7 +272,10 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ]; }, [firstTableRef, onRowContextMenuClick, columnConfig, hasAtLeastOneRowClickAction]); - const renderCellValue = useMemo(() => createGridCell(formatters, DataContext), [formatters]); + const renderCellValue = useMemo( + () => createGridCell(formatters, columnConfig, DataContext, props.uiSettings), + [formatters, columnConfig, props.uiSettings] + ); const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns: () => {} }), [ visibleColumns, @@ -286,6 +307,8 @@ export const DatatableComponent = (props: DatatableRenderProps) => { table: firstLocalTable, rowHasRowClickTriggerActions: props.rowHasRowClickTriggerActions, alignments, + minMaxByColumnId, + getColorForValue: props.paletteService.get(CUSTOM_PALETTE).getColorForValue!, }} > IAggType; renderMode: RenderMode; + paletteService: PaletteRegistry; + uiSettings: IUiSettingsClient; /** * A boolean for each table row, which is true if the row active @@ -55,4 +59,10 @@ export interface DataContextType { table?: Datatable; rowHasRowClickTriggerActions?: boolean[]; alignments?: Record; + minMaxByColumnId?: Record; + getColorForValue?: ( + value: number | undefined, + state: CustomPaletteState, + minMax: { min: number; max: number } + ) => string | undefined; } diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 7d879217abf8b2..2d5f4aea988562 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -17,6 +17,9 @@ import { ExpressionFunctionDefinition, ExpressionRenderDefinition, } from 'src/plugins/expressions'; +import { CustomPaletteState, PaletteOutput } from 'src/plugins/charts/common'; +import { PaletteRegistry } from 'src/plugins/charts/public'; +import { IUiSettingsClient } from 'kibana/public'; import { getSortingCriteria } from './sorting'; import { DatatableComponent } from './components/table_basic'; @@ -26,10 +29,15 @@ import type { FormatFactory, ILensInterpreterRenderHandlers, LensMultiTable } fr import type { DatatableRender } from './components/types'; import { transposeTable } from './transpose_helpers'; +export type ColumnConfigArg = Omit & { + type: 'lens_datatable_column'; + palette?: PaletteOutput; +}; + export interface Args { title: string; description?: string; - columns: Array; + columns: ColumnConfigArg[]; sortingColumnId: string | undefined; sortingDirection: 'asc' | 'desc' | 'none'; } @@ -160,6 +168,11 @@ export const datatableColumn: ExpressionFunctionDefinition< width: { types: ['number'], help: '' }, isTransposed: { types: ['boolean'], help: '' }, transposable: { types: ['boolean'], help: '' }, + colorMode: { types: ['string'], help: '' }, + palette: { + types: ['palette'], + help: '', + }, }, fn: function fn(input: unknown, args: ColumnState) { return { @@ -172,6 +185,8 @@ export const datatableColumn: ExpressionFunctionDefinition< export const getDatatableRenderer = (dependencies: { formatFactory: FormatFactory; getType: Promise<(name: string) => IAggType>; + paletteService: PaletteRegistry; + uiSettings: IUiSettingsClient; }): ExpressionRenderDefinition => ({ name: 'lens_datatable_renderer', displayName: i18n.translate('xpack.lens.datatable.visualizationName', { @@ -222,8 +237,10 @@ export const getDatatableRenderer = (dependencies: { formatFactory={dependencies.formatFactory} dispatchEvent={handlers.event} renderMode={handlers.getRenderMode()} + paletteService={dependencies.paletteService} getType={resolvedGetType} rowHasRowClickTriggerActions={rowHasRowClickTriggerActions} + uiSettings={dependencies.uiSettings} /> , domNode, diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index f0939f61952294..7f48d00d00f7f7 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -6,6 +6,7 @@ */ import { CoreSetup } from 'kibana/public'; +import { ChartsPluginSetup } from 'src/plugins/charts/public'; import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import { EditorFrameSetup, FormatFactory } from '../types'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; @@ -17,6 +18,7 @@ export interface DatatableVisualizationPluginSetupPlugins { expressions: ExpressionsSetup; formatFactory: Promise; editorFrame: EditorFrameSetup; + charts: ChartsPluginSetup; } export class DatatableVisualization { @@ -24,15 +26,16 @@ export class DatatableVisualization { setup( core: CoreSetup, - { expressions, formatFactory, editorFrame }: DatatableVisualizationPluginSetupPlugins + { expressions, formatFactory, editorFrame, charts }: DatatableVisualizationPluginSetupPlugins ) { editorFrame.registerVisualization(async () => { const { getDatatable, datatableColumn, getDatatableRenderer, - datatableVisualization, + getDatatableVisualization, } = await import('../async_services'); + const palettes = await charts.palettes.getPalettes(); const resolvedFormatFactory = await formatFactory; expressions.registerFunction(() => datatableColumn); @@ -43,9 +46,11 @@ export class DatatableVisualization { getType: core .getStartServices() .then(([_, { data: dataStart }]) => dataStart.search.aggs.types.get), + paletteService: palettes, + uiSettings: core.uiSettings, }) ); - return datatableVisualization; + return getDatatableVisualization({ paletteService: palettes }); }); } } diff --git a/x-pack/plugins/lens/public/datatable_visualization/transpose_helpers.ts b/x-pack/plugins/lens/public/datatable_visualization/transpose_helpers.ts index 6e29e018b481e2..a35edf7499073a 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/transpose_helpers.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/transpose_helpers.ts @@ -7,9 +7,9 @@ import type { FieldFormat } from 'src/plugins/data/public'; import type { Datatable, DatatableColumn, DatatableRow } from 'src/plugins/expressions'; +import { ColumnConfig } from './components/table_basic'; -import { Args } from './expression'; -import { ColumnState } from './visualization'; +import { Args, ColumnConfigArg } from './expression'; const TRANSPOSE_SEPARATOR = '---'; @@ -87,11 +87,11 @@ export function transposeTable( function transposeRows( firstTable: Datatable, - bucketsColumnArgs: Array, + bucketsColumnArgs: ColumnConfigArg[], formatters: Record, transposedColumnFormatter: FieldFormat, transposedColumnId: string, - metricsColumnArgs: Array + metricsColumnArgs: ColumnConfigArg[] ) { const rowsByBucketColumns: Record = groupRowsByBucketColumns( firstTable, @@ -113,8 +113,8 @@ function transposeRows( */ function updateColumnArgs( args: Args, - bucketsColumnArgs: Array, - transposedColumnGroups: Array> + bucketsColumnArgs: ColumnConfig['columns'], + transposedColumnGroups: Array ) { args.columns = [...bucketsColumnArgs]; // add first column from each group, then add second column for each group, ... @@ -151,8 +151,8 @@ function getUniqueValues(table: Datatable, formatter: FieldFormat, columnId: str */ function transposeColumns( args: Args, - bucketsColumnArgs: Array, - metricColumns: Array, + bucketsColumnArgs: ColumnConfig['columns'], + metricColumns: ColumnConfig['columns'], firstTable: Datatable, uniqueValues: string[], uniqueRawValues: unknown[], @@ -196,10 +196,10 @@ function transposeColumns( */ function mergeRowGroups( rowsByBucketColumns: Record, - bucketColumns: ColumnState[], + bucketColumns: ColumnConfigArg[], formatter: FieldFormat, transposedColumnId: string, - metricColumns: ColumnState[] + metricColumns: ColumnConfigArg[] ) { return Object.values(rowsByBucketColumns).map((rows) => { const mergedRow: DatatableRow = {}; @@ -222,7 +222,7 @@ function mergeRowGroups( */ function groupRowsByBucketColumns( firstTable: Datatable, - bucketColumns: ColumnState[], + bucketColumns: ColumnConfigArg[], formatters: Record ) { const rowsByBucketColumns: Record = {}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 1848565114dea5..ea8237defc2911 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -8,7 +8,7 @@ import { Ast } from '@kbn/interpreter/common'; import { buildExpression } from '../../../../../src/plugins/expressions/public'; import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; -import { DatatableVisualizationState, datatableVisualization } from './visualization'; +import { DatatableVisualizationState, getDatatableVisualization } from './visualization'; import { Operation, DataType, @@ -16,6 +16,7 @@ import { TableSuggestionColumn, VisualizationDimensionGroupConfig, } from '../types'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; function mockFrame(): FramePublicAPI { return { @@ -32,6 +33,10 @@ function mockFrame(): FramePublicAPI { }; } +const datatableVisualization = getDatatableVisualization({ + paletteService: chartPluginMock.createPaletteRegistry(), +}); + describe('Datatable Visualization', () => { describe('#initialize', () => { it('should initialize from the empty state', () => { @@ -427,22 +432,28 @@ describe('Datatable Visualization', () => { ); const columnArgs = buildExpression(expression).findFunction('lens_datatable_column'); expect(columnArgs).toHaveLength(2); - expect(columnArgs[0].arguments).toEqual({ - columnId: ['c'], - hidden: [], - width: [], - isTransposed: [], - transposable: [true], - alignment: [], - }); - expect(columnArgs[1].arguments).toEqual({ - columnId: ['b'], - hidden: [], - width: [], - isTransposed: [], - transposable: [true], - alignment: [], - }); + expect(columnArgs[0].arguments).toEqual( + expect.objectContaining({ + columnId: ['c'], + hidden: [], + width: [], + isTransposed: [], + transposable: [true], + alignment: [], + colorMode: ['none'], + }) + ); + expect(columnArgs[1].arguments).toEqual( + expect.objectContaining({ + columnId: ['b'], + hidden: [], + width: [], + isTransposed: [], + transposable: [true], + alignment: [], + colorMode: ['none'], + }) + ); }); it('returns no expression if the metric dimension is not defined', () => { diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 9bd482c73bff58..efde4160019e73 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -11,6 +11,7 @@ import { Ast } from '@kbn/interpreter/common'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { DatatableColumn } from 'src/plugins/expressions/public'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; import { SuggestionRequest, Visualization, @@ -19,6 +20,9 @@ import { } from '../types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; import { TableDimensionEditor } from './components/dimension_editor'; +import { CUSTOM_PALETTE } from '../shared_components/coloring/constants'; +import { CustomPaletteParams } from '../shared_components/coloring/types'; +import { getStopsForFixedMode } from '../shared_components'; export interface ColumnState { columnId: string; @@ -32,6 +36,8 @@ export interface ColumnState { originalName?: string; bucketValues?: Array<{ originalBucketColumn: DatatableColumn; value: unknown }>; alignment?: 'left' | 'right' | 'center'; + palette?: PaletteOutput; + colorMode?: 'none' | 'cell' | 'text'; } export interface SortingState { @@ -49,7 +55,11 @@ const visualizationLabel = i18n.translate('xpack.lens.datatable.label', { defaultMessage: 'Table', }); -export const datatableVisualization: Visualization = { +export const getDatatableVisualization = ({ + paletteService, +}: { + paletteService: PaletteRegistry; +}): Visualization => ({ id: 'lnsDatatable', visualizationTypes: [ @@ -239,10 +249,26 @@ export const datatableVisualization: Visualization layerId: state.layerId, accessors: sortedColumns .filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed) - .map((accessor) => ({ - columnId: accessor, - triggerIcon: columnMap[accessor].hidden ? 'invisible' : undefined, - })), + .map((accessor) => { + const columnConfig = columnMap[accessor]; + const hasColoring = Boolean( + columnConfig.colorMode !== 'none' && columnConfig.palette?.params?.stops + ); + return { + columnId: accessor, + triggerIcon: columnConfig.hidden + ? 'invisible' + : hasColoring + ? 'colorBy' + : undefined, + palette: hasColoring + ? getStopsForFixedMode( + columnConfig.palette?.params?.stops || [], + columnConfig.palette?.params?.colorStops + ) + : undefined, + }; + }), supportsMoreColumns: true, filterOperations: (op) => !op.isBucketed, required: true, @@ -285,7 +311,7 @@ export const datatableVisualization: Visualization renderDimensionEditor(domElement, props) { render( - + , domElement ); @@ -320,26 +346,41 @@ export const datatableVisualization: Visualization arguments: { title: [title || ''], description: [description || ''], - columns: columns.map((column) => ({ - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_datatable_column', - arguments: { - columnId: [column.columnId], - hidden: typeof column.hidden === 'undefined' ? [] : [column.hidden], - width: typeof column.width === 'undefined' ? [] : [column.width], - isTransposed: - typeof column.isTransposed === 'undefined' ? [] : [column.isTransposed], - transposable: [ - !datasource!.getOperationForColumnId(column.columnId)?.isBucketed, - ], - alignment: typeof column.alignment === 'undefined' ? [] : [column.alignment], + columns: columns.map((column) => { + const paletteParams = { + ...column.palette?.params, + // rewrite colors and stops as two distinct arguments + colors: (column.palette?.params?.stops || []).map(({ color }) => color), + stops: + column.palette?.params?.name === 'custom' + ? (column.palette?.params?.stops || []).map(({ stop }) => stop) + : [], + reverse: false, // managed at UI level + }; + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable_column', + arguments: { + columnId: [column.columnId], + hidden: typeof column.hidden === 'undefined' ? [] : [column.hidden], + width: typeof column.width === 'undefined' ? [] : [column.width], + isTransposed: + typeof column.isTransposed === 'undefined' ? [] : [column.isTransposed], + transposable: [ + !datasource!.getOperationForColumnId(column.columnId)?.isBucketed, + ], + alignment: typeof column.alignment === 'undefined' ? [] : [column.alignment], + colorMode: [column.colorMode ?? 'none'], + palette: [paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams)], + }, }, - }, - ], - })), + ], + }; + }), sortingColumnId: [state.sorting?.columnId || ''], sortingDirection: [state.sorting?.direction || 'none'], }, @@ -395,7 +436,7 @@ export const datatableVisualization: Visualization return state; } }, -}; +}); function getDataSourceAndSortedColumns( state: DatatableVisualizationState, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index b8d3170b3e1650..a8d610f2740de5 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -29,11 +29,13 @@ export function DimensionContainer({ groupLabel, handleClose, panel, + panelRef, }: { isOpen: boolean; handleClose: () => void; panel: React.ReactElement; groupLabel: string; + panelRef: (el: HTMLDivElement) => void; }) { const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false); @@ -73,65 +75,67 @@ export function DimensionContainer({ }); return isOpen ? ( - - - -
- - - - - - - -

- - {i18n.translate('xpack.lens.configure.configurePanelTitle', { - defaultMessage: '{groupLabel} configuration', - values: { - groupLabel, - }, - })} - -

-
-
-
-
- - {panel} - - - - {i18n.translate('xpack.lens.dimensionContainer.close', { - defaultMessage: 'Close', - })} - - -
-
-
+
+ + + +
+ + + + +

+ + {i18n.translate('xpack.lens.configure.configurePanelTitle', { + defaultMessage: '{groupLabel} configuration', + values: { + groupLabel, + }, + })} + +

+
+
+ + + +
+
+ + {panel} + + + + {i18n.translate('xpack.lens.dimensionContainer.close', { + defaultMessage: 'Close', + })} + + +
+
+
+
) : null; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index cf3c9099d4b0dd..a605a94a346468 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -7,7 +7,7 @@ import './layer_panel.scss'; -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { EuiPanel, EuiSpacer, @@ -72,6 +72,7 @@ export function LayerPanel( setActiveDimension(initialActiveDimensionState); }, [activeVisualization.id]); + const panelRef = useRef(null); const registerLayerRef = useCallback((el) => registerNewLayerRef(layerId, el), [ layerId, registerNewLayerRef, @@ -405,6 +406,7 @@ export function LayerPanel( (panelRef.current = el)} isOpen={!!activeId} groupLabel={activeGroup?.groupLabel || ''} handleClose={() => { @@ -484,6 +486,7 @@ export function LayerPanel( groupId: activeGroup.groupId, accessor: activeId, setState: props.updateVisualization, + panelRef, }} />
diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx index a56b3ccaa5bde7..38669d72474df1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx @@ -105,10 +105,9 @@ export type FrameMock = jest.Mocked; export function createMockPaletteDefinition(): jest.Mocked { return { - getColors: jest.fn((_) => ['#ff0000', '#00ff00']), + getCategoricalColors: jest.fn((_) => ['#ff0000', '#00ff00']), title: 'Mock Palette', id: 'default', - renderEditor: jest.fn(), toExpression: jest.fn(() => ({ type: 'expression', chain: [ @@ -119,7 +118,7 @@ export function createMockPaletteDefinition(): jest.Mocked { }, ], })), - getColor: jest.fn().mockReturnValue('#ff0000'), + getCategoricalColor: jest.fn().mockReturnValue('#ff0000'), }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index 8d18a2752fd7e3..0e74ef6b85c804 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useState } from 'react'; import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; +import { useDebounceWithOptions } from '../../../../shared_components'; import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types'; import { IndexPatternLayer } from '../../../types'; import { @@ -19,12 +20,7 @@ import { hasDateField, } from './utils'; import { updateColumnParam } from '../../layer_helpers'; -import { - getFormatFromPreviousColumn, - isValidNumber, - useDebounceWithOptions, - getFilter, -} from '../helpers'; +import { getFormatFromPreviousColumn, isValidNumber, getFilter } from '../helpers'; import { adjustTimeScaleOnOtherColumnChange } from '../../time_scale_utils'; import { HelpPopover, HelpPopoverButton } from '../../../help_popover'; import type { OperationDefinition, ParamEditorProps } from '..'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx index f719ac4250912e..45abbcd3d9cf96 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx @@ -5,35 +5,11 @@ * 2.0. */ -import { useRef } from 'react'; -import useDebounce from 'react-use/lib/useDebounce'; import { i18n } from '@kbn/i18n'; import { IndexPatternColumn, operationDefinitionMap } from '.'; import { FieldBasedIndexPatternColumn } from './column_types'; import { IndexPattern } from '../../types'; -export const useDebounceWithOptions = ( - fn: Function, - { skipFirstRender }: { skipFirstRender: boolean } = { skipFirstRender: false }, - ms?: number | undefined, - deps?: React.DependencyList | undefined -) => { - const isFirstRender = useRef(true); - const newDeps = [...(deps || []), isFirstRender]; - - return useDebounce( - () => { - if (skipFirstRender && isFirstRender.current) { - isFirstRender.current = false; - return; - } - return fn(); - }, - ms, - newDeps - ); -}; - export function getInvalidFieldMessage( column: FieldBasedIndexPatternColumn, indexPattern?: IndexPattern diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx index 705a1f7172fff8..4c09ae4ed8c47b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx @@ -16,10 +16,10 @@ import { getInvalidFieldMessage, getSafeName, isValidNumber, - useDebounceWithOptions, getFilter, } from './helpers'; import { FieldBasedIndexPatternColumn } from './column_types'; +import { useDebounceWithOptions } from '../../../shared_components'; export interface PercentileIndexPatternColumn extends FieldBasedIndexPatternColumn { operationType: 'percentile'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx index b3ffb58df00d39..43f5527e42d4bf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx @@ -22,6 +22,7 @@ import { htmlIdGenerator, keys, } from '@elastic/eui'; +import { useDebounceWithOptions } from '../../../../shared_components'; import { IFieldFormat } from '../../../../../../../../src/plugins/data/common'; import { RangeTypeLens, isValidRange } from './ranges'; import { FROM_PLACEHOLDER, TO_PLACEHOLDER, TYPING_DEBOUNCE_TIME } from './constants'; @@ -31,7 +32,7 @@ import { DraggableBucketContainer, LabelInput, } from '../shared_components'; -import { isValidNumber, useDebounceWithOptions } from '../helpers'; +import { isValidNumber } from '../helpers'; const generateId = htmlIdGenerator(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx index 4851b6ff3ec975..3389c723b4daf6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx @@ -23,7 +23,7 @@ import { UI_SETTINGS } from '../../../../../../../../src/plugins/data/public'; import { RangeColumnParams, UpdateParamsFnType, MODES_TYPES } from './ranges'; import { AdvancedRangeEditor } from './advanced_editor'; import { TYPING_DEBOUNCE_TIME, MODES, MIN_HISTOGRAM_BARS } from './constants'; -import { useDebounceWithOptions } from '../helpers'; +import { useDebounceWithOptions } from '../../../../shared_components'; import { HelpPopover, HelpPopoverButton } from '../../../help_popover'; const GranularityHelpPopover = () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx index 915e67c4eba0bd..a4c0f8f1c50e05 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx @@ -8,7 +8,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFieldNumber } from '@elastic/eui'; -import { useDebounceWithOptions } from '../helpers'; +import { useDebounceWithOptions } from '../../../../shared_components'; export const ValuesInput = ({ value, diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index 7191da0af6bfe7..a9e7e4adb9ca78 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -161,7 +161,7 @@ describe('PieVisualization component', () => { [] as HierarchyOfArrays ); - expect(defaultArgs.paletteService.get('mock').getColor).toHaveBeenCalledWith( + expect(defaultArgs.paletteService.get('mock').getCategoricalColor).toHaveBeenCalledWith( [ { name: 'css', diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index cc31222f6b9ab0..6c1cbe63a5a3e3 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -150,7 +150,7 @@ export function PieComponent( } } - const outputColor = paletteService.get(palette.name).getColor( + const outputColor = paletteService.get(palette.name).getCategoricalColor( seriesLayers, { behindText: categoryDisplay !== 'hide', diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index ad8d87292b1d80..f413b122d913cc 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -126,7 +126,7 @@ export const getPieVisualization = ({ triggerIcon: 'colorBy', palette: paletteService .get(state.palette?.name || 'default') - .getColors(10, state.palette?.params), + .getCategoricalColors(10, state.palette?.params), }; } diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx new file mode 100644 index 00000000000000..54c7f3cef90fe3 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx @@ -0,0 +1,156 @@ +/* + * 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 { EuiColorPicker } from '@elastic/eui'; +import { mount } from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { CustomStops, CustomStopsProps } from './color_stops'; + +describe('Color Stops component', () => { + let props: CustomStopsProps; + beforeEach(() => { + props = { + colorStops: [ + { color: '#aaa', stop: 20 }, + { color: '#bbb', stop: 40 }, + { color: '#ccc', stop: 60 }, + ], + paletteConfiguration: {}, + dataBounds: { min: 0, max: 200 }, + onChange: jest.fn(), + 'data-test-prefix': 'my-test', + }; + }); + it('should display all the color stops passed', () => { + const component = mount(); + expect( + component.find('input[data-test-subj^="my-test_dynamicColoring_stop_value_"]') + ).toHaveLength(3); + }); + + it('should disable the delete buttons when there are 2 stops or less', () => { + // reduce to 2 stops + props.colorStops = props.colorStops.slice(0, 2); + const component = mount(); + expect( + component + .find('[data-test-subj="my-test_dynamicColoring_removeStop_0"]') + .first() + .prop('isDisabled') + ).toBe(true); + }); + + it('should add a new stop with default color and reasonable distance from last one', () => { + let component = mount(); + const addStopButton = component + .find('[data-test-subj="my-test_dynamicColoring_addStop"]') + .first(); + act(() => { + addStopButton.prop('onClick')!({} as React.MouseEvent); + }); + component = component.update(); + + expect( + component.find('input[data-test-subj^="my-test_dynamicColoring_stop_value_"]') + ).toHaveLength(4); + expect( + component.find('input[data-test-subj="my-test_dynamicColoring_stop_value_3"]').prop('value') + ).toBe('80'); // 60-40 + 60 + expect( + component + // workaround for https://github.com/elastic/eui/issues/4792 + .find('[data-test-subj="my-test_dynamicColoring_stop_color_3"]') + .last() // pick the inner element + .childAt(0) + .prop('color') + ).toBe('#ccc'); // pick previous color + }); + + it('should restore previous color when abandoning the field with an empty color', () => { + let component = mount(); + expect( + component + .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') + .first() + .find(EuiColorPicker) + .first() + .prop('color') + ).toBe('#aaa'); + act(() => { + component + .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') + .first() + .find(EuiColorPicker) + .first() + .prop('onChange')!('', { + rgba: [NaN, NaN, NaN, NaN], + hex: '', + isValid: false, + }); + }); + component = component.update(); + expect( + component + .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') + .first() + .find(EuiColorPicker) + .first() + .prop('color') + ).toBe(''); + act(() => { + component + .find('[data-test-subj="my-test_dynamicColoring_stop_color_0"]') + .first() + .prop('onBlur')!({} as React.FocusEvent); + }); + component = component.update(); + expect( + component + .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') + .first() + .find(EuiColorPicker) + .first() + .prop('color') + ).toBe('#aaa'); + }); + + it('should sort stops value on whole component blur', () => { + let component = mount(); + let firstStopValueInput = component + .find('[data-test-subj="my-test_dynamicColoring_stop_value_0"]') + .first(); + act(() => { + firstStopValueInput.prop('onChange')!(({ + target: { value: ' 90' }, + } as unknown) as React.ChangeEvent); + }); + + component = component.update(); + + act(() => { + component + .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') + .first() + .prop('onBlur')!({} as React.FocusEvent); + }); + component = component.update(); + + // retrieve again the input + firstStopValueInput = component + .find('[data-test-subj="my-test_dynamicColoring_stop_value_0"]') + .first(); + expect(firstStopValueInput.prop('value')).toBe('40'); + // the previous one move at the bottom + expect( + component + .find('[data-test-subj="my-test_dynamicColoring_stop_value_2"]') + .first() + .prop('value') + ).toBe('90'); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx new file mode 100644 index 00000000000000..37197b232ddf55 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx @@ -0,0 +1,294 @@ +/* + * 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 React, { useState, useCallback, useMemo } from 'react'; +import type { FocusEvent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFieldNumber, + EuiColorPicker, + EuiButtonIcon, + EuiFlexItem, + EuiFlexGroup, + EuiButtonEmpty, + EuiSpacer, + EuiScreenReaderOnly, + htmlIdGenerator, +} from '@elastic/eui'; +import useUnmount from 'react-use/lib/useUnmount'; +import { DEFAULT_COLOR } from './constants'; +import { getDataMinMax, getStepValue, isValidColor } from './utils'; +import { TooltipWrapper, useDebouncedValue } from '../index'; +import { ColorStop, CustomPaletteParams } from './types'; + +const idGeneratorFn = htmlIdGenerator(); + +function areStopsValid(colorStops: Array<{ color: string; stop: string }>) { + return colorStops.every( + ({ color, stop }) => isValidColor(color) && !Number.isNaN(parseFloat(stop)) + ); +} + +function shouldSortStops(colorStops: Array<{ color: string; stop: string | number }>) { + return colorStops.some(({ stop }, i) => { + const numberStop = Number(stop); + const prevNumberStop = Number(colorStops[i - 1]?.stop ?? -Infinity); + return numberStop < prevNumberStop; + }); +} + +export interface CustomStopsProps { + colorStops: ColorStop[]; + onChange: (colorStops: ColorStop[]) => void; + dataBounds: { min: number; max: number }; + paletteConfiguration: CustomPaletteParams | undefined; + 'data-test-prefix': string; +} +export const CustomStops = ({ + colorStops, + onChange, + paletteConfiguration, + dataBounds, + ['data-test-prefix']: dataTestPrefix, +}: CustomStopsProps) => { + const onChangeWithValidation = useCallback( + (newColorStops: Array<{ color: string; stop: string }>) => { + const areStopsValuesValid = areStopsValid(newColorStops); + const shouldSort = shouldSortStops(newColorStops); + if (areStopsValuesValid && !shouldSort) { + onChange(newColorStops.map(({ color, stop }) => ({ color, stop: Number(stop) }))); + } + }, + [onChange] + ); + + const memoizedValues = useMemo(() => { + return colorStops.map(({ color, stop }, i) => ({ + color, + stop: String(stop), + id: idGeneratorFn(), + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [paletteConfiguration?.name, paletteConfiguration?.reverse, paletteConfiguration?.rangeType]); + + const { inputValue: localColorStops, handleInputChange: setLocalColorStops } = useDebouncedValue({ + onChange: onChangeWithValidation, + value: memoizedValues, + }); + const [sortedReason, setSortReason] = useState(''); + const shouldEnableDelete = localColorStops.length > 2; + + const [popoverInFocus, setPopoverInFocus] = useState(false); + + // refresh on unmount: + // the onChange logic here is a bit different than the one above as it has to actively sort if required + useUnmount(() => { + const areStopsValuesValid = areStopsValid(localColorStops); + const shouldSort = shouldSortStops(localColorStops); + if (areStopsValuesValid && shouldSort) { + onChange( + localColorStops + .map(({ color, stop }) => ({ color, stop: Number(stop) })) + .sort(({ stop: stopA }, { stop: stopB }) => Number(stopA) - Number(stopB)) + ); + } + }); + + const rangeType = paletteConfiguration?.rangeType || 'percent'; + + return ( + <> + {sortedReason ? ( + +

+ {i18n.translate('xpack.lens.dynamicColoring.customPalette.sortReason', { + defaultMessage: 'Color stops have been sorted due to new stop value {value}', + values: { + value: sortedReason, + }, + })} +

+
+ ) : null} + + + {localColorStops.map(({ color, stop, id }, index) => { + const prevStopValue = Number(localColorStops[index - 1]?.stop ?? -Infinity); + const nextStopValue = Number(localColorStops[index + 1]?.stop ?? Infinity); + + return ( + ) => { + // sort the stops when the focus leaves the row container + const shouldSort = Number(stop) > nextStopValue || prevStopValue > Number(stop); + const isFocusStillInContent = + (e.currentTarget as Node)?.contains(e.relatedTarget as Node) || popoverInFocus; + const hasInvalidColor = !isValidColor(color); + if ((shouldSort && !isFocusStillInContent) || hasInvalidColor) { + // replace invalid color with previous valid one + const lastValidColor = hasInvalidColor ? colorStops[index].color : color; + const localColorStopsCopy = localColorStops.map((item, i) => + i === index ? { color: lastValidColor, stop, id } : item + ); + setLocalColorStops( + localColorStopsCopy.sort( + ({ stop: stopA }, { stop: stopB }) => Number(stopA) - Number(stopB) + ) + ); + setSortReason(stop); + } + }} + > + + + { + const newStopString = target.value.trim(); + const newColorStops = [...localColorStops]; + newColorStops[index] = { + color, + stop: newStopString, + id, + }; + setLocalColorStops(newColorStops); + }} + append={rangeType === 'percent' ? '%' : undefined} + aria-label={i18n.translate( + 'xpack.lens.dynamicColoring.customPalette.stopAriaLabel', + { + defaultMessage: 'Stop {index}', + values: { + index: index + 1, + }, + } + )} + /> + + + { + // make sure that the popover is closed + if (color === '' && !popoverInFocus) { + const newColorStops = [...localColorStops]; + newColorStops[index] = { color: colorStops[index].color, stop, id }; + setLocalColorStops(newColorStops); + } + }} + > + { + const newColorStops = [...localColorStops]; + newColorStops[index] = { color: newColor, stop, id }; + setLocalColorStops(newColorStops); + }} + secondaryInputDisplay="top" + color={color} + isInvalid={!isValidColor(color)} + showAlpha + compressed + onFocus={() => setPopoverInFocus(true)} + onBlur={() => { + setPopoverInFocus(false); + if (color === '') { + const newColorStops = [...localColorStops]; + newColorStops[index] = { color: colorStops[index].color, stop, id }; + setLocalColorStops(newColorStops); + } + }} + placeholder=" " + /> + + + + + { + const newColorStops = localColorStops.filter((_, i) => i !== index); + setLocalColorStops(newColorStops); + }} + data-test-subj={`${dataTestPrefix}_dynamicColoring_removeStop_${index}`} + isDisabled={!shouldEnableDelete} + /> + + + + + ); + })} + + + + + { + const newColorStops = [...localColorStops]; + const length = newColorStops.length; + const { max } = getDataMinMax(rangeType, dataBounds); + const step = getStepValue( + colorStops, + newColorStops.map(({ color, stop }) => ({ color, stop: Number(stop) })), + max + ); + const prevColor = localColorStops[length - 1].color || DEFAULT_COLOR; + const newStop = step + Number(localColorStops[length - 1].stop); + newColorStops.push({ + color: prevColor, + stop: String(newStop), + id: idGeneratorFn(), + }); + setLocalColorStops(newColorStops); + }} + > + {i18n.translate('xpack.lens.dynamicColoring.customPalette.addColorStop', { + defaultMessage: 'Add color stop', + })} + + + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/constants.ts b/x-pack/plugins/lens/public/shared_components/coloring/constants.ts new file mode 100644 index 00000000000000..5e6fc207656ac3 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/constants.ts @@ -0,0 +1,29 @@ +/* + * 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 { RequiredPaletteParamTypes } from './types'; + +export const DEFAULT_PALETTE_NAME = 'positive'; +export const FIXED_PROGRESSION = 'fixed' as const; +export const CUSTOM_PALETTE = 'custom'; +export const DEFAULT_CONTINUITY = 'above'; +export const DEFAULT_MIN_STOP = 0; +export const DEFAULT_MAX_STOP = 100; +export const DEFAULT_COLOR_STEPS = 5; +export const DEFAULT_COLOR = '#6092C0'; // Same as EUI ColorStops default for new stops +export const defaultPaletteParams: RequiredPaletteParamTypes = { + name: DEFAULT_PALETTE_NAME, + reverse: false, + rangeType: 'percent', + rangeMin: DEFAULT_MIN_STOP, + rangeMax: DEFAULT_MAX_STOP, + progression: FIXED_PROGRESSION, + stops: [], + steps: DEFAULT_COLOR_STEPS, + colorStops: [], + continuity: DEFAULT_CONTINUITY, +}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/index.ts b/x-pack/plugins/lens/public/shared_components/coloring/index.ts new file mode 100644 index 00000000000000..3b34c6662c6819 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export { CustomizablePalette } from './palette_configuration'; +export { CustomStops } from './color_stops'; +export * from './types'; +export * from './utils'; +export * from './constants'; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.scss b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.scss new file mode 100644 index 00000000000000..c6b14c5c5f9a35 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.scss @@ -0,0 +1,7 @@ +.lnsPalettePanel__section--shaded { + background-color: $euiColorLightestShade; +} + +.lnsPalettePanel__section { + padding: $euiSizeS; +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx new file mode 100644 index 00000000000000..28ba28a5801e40 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx @@ -0,0 +1,185 @@ +/* + * 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 React from 'react'; +import { EuiColorPalettePickerPaletteProps } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import { ReactWrapper } from 'enzyme'; +import { CustomPaletteParams } from './types'; +import { applyPaletteParams } from './utils'; +import { CustomizablePalette } from './palette_configuration'; + +describe('palette utilities', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + describe('applyPaletteParams', () => { + it('should return a set of colors for a basic configuration', () => { + expect( + applyPaletteParams( + paletteRegistry, + { type: 'palette', name: 'positive' }, + { min: 0, max: 100 } + ) + ).toEqual([ + { color: 'blue', stop: 20 }, + { color: 'yellow', stop: 70 }, + ]); + }); + + it('should reverse the palette color stops correctly', () => { + expect( + applyPaletteParams( + paletteRegistry, + { + type: 'palette', + name: 'positive', + params: { reverse: true }, + }, + { min: 0, max: 100 } + ) + ).toEqual([ + { color: 'yellow', stop: 20 }, + { color: 'blue', stop: 70 }, + ]); + }); + }); +}); + +describe('palette panel', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + let props: { + palettes: PaletteRegistry; + activePalette: PaletteOutput; + setPalette: (palette: PaletteOutput) => void; + dataBounds: { min: number; max: number }; + }; + + describe('palette picker', () => { + beforeEach(() => { + props = { + activePalette: { type: 'palette', name: 'positive' }, + palettes: paletteRegistry, + setPalette: jest.fn(), + dataBounds: { min: 0, max: 100 }, + }; + }); + + function changePaletteIn(instance: ReactWrapper, newPaletteName: string) { + return ((instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_palette_picker"]') + .at(1) + .prop('onChange') as unknown) as (value: string) => void)?.(newPaletteName); + } + + it('should show only dynamic coloring enabled palette + custom option', () => { + const instance = mountWithIntl(); + const paletteOptions = instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_palette_picker"]') + .at(1) + .prop('palettes') as EuiColorPalettePickerPaletteProps[]; + expect(paletteOptions.length).toEqual(2); + + expect(paletteOptions[paletteOptions.length - 1]).toEqual({ + title: 'Custom Mocked Palette', // <- picks the title of the custom palette + type: 'fixed', + value: 'custom', + palette: ['blue', 'yellow'], + 'data-test-subj': 'custom-palette', + }); + }); + + it('should set the colorStops and stops when selecting the Custom palette from the list', () => { + const instance = mountWithIntl(); + + changePaletteIn(instance, 'custom'); + + expect(props.setPalette).toHaveBeenCalledWith({ + type: 'palette', + name: 'custom', + params: expect.objectContaining({ + colorStops: [ + { color: 'blue', stop: 0 }, + { color: 'yellow', stop: 50 }, + ], + stops: [ + { color: 'blue', stop: 50 }, + { color: 'yellow', stop: 100 }, + ], + name: 'custom', + }), + }); + }); + + describe('reverse option', () => { + beforeEach(() => { + props = { + activePalette: { type: 'palette', name: 'positive' }, + palettes: paletteRegistry, + setPalette: jest.fn(), + dataBounds: { min: 0, max: 100 }, + }; + }); + + function toggleReverse(instance: ReactWrapper, checked: boolean) { + return instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_reverse"]') + .first() + .prop('onClick')!({} as React.MouseEvent); + } + + it('should reverse the colorStops on click', () => { + const instance = mountWithIntl(); + + toggleReverse(instance, true); + + expect(props.setPalette).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + reverse: true, + }), + }) + ); + }); + }); + + describe('custom stops', () => { + beforeEach(() => { + props = { + activePalette: { type: 'palette', name: 'positive' }, + palettes: paletteRegistry, + setPalette: jest.fn(), + dataBounds: { min: 0, max: 100 }, + }; + }); + it('should be visible for predefined palettes', () => { + const instance = mountWithIntl(); + expect( + instance.find('[data-test-subj="lnsDatatable_dynamicColoring_custom_stops"]').exists() + ).toEqual(true); + }); + + it('should be visible for custom palettes', () => { + const instance = mountWithIntl( + + ); + expect( + instance.find('[data-test-subj="lnsDatatable_dynamicColoring_custom_stops"]').exists() + ).toEqual(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx new file mode 100644 index 00000000000000..df01b3e57cd7df --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx @@ -0,0 +1,340 @@ +/* + * 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 React from 'react'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import { + EuiFormRow, + htmlIdGenerator, + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiSuperSelect, + EuiIcon, + EuiIconTip, + EuiLink, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { PalettePicker } from './palette_picker'; + +import './palette_configuration.scss'; + +import { CustomStops } from './color_stops'; +import { defaultPaletteParams, CUSTOM_PALETTE, DEFAULT_COLOR_STEPS } from './constants'; +import { CustomPaletteParams, RequiredPaletteParamTypes } from './types'; +import { + getColorStops, + getPaletteStops, + mergePaletteParams, + getDataMinMax, + remapStopsByNewInterval, + getSwitchToCustomParams, + reversePalette, + roundStopValues, +} from './utils'; +const idPrefix = htmlIdGenerator()(); + +/** + * Some name conventions here: + * * `displayStops` => It's an additional transformation of `stops` into a [0, N] domain for the EUIPaletteDisplay component. + * * `stops` => final steps used to table coloring. It is a rightShift of the colorStops + * * `colorStops` => user's color stop inputs. Used to compute range min. + * + * When the user inputs the colorStops, they are designed to be the initial part of the color segment, + * so the next stops indicate where the previous stop ends. + * Both table coloring logic and EuiPaletteDisplay format implementation works differently than our current `colorStops`, + * by having the stop values at the end of each color segment rather than at the beginning: `stops` values are computed by a rightShift of `colorStops`. + * EuiPaletteDisplay has an additional requirement as it is always mapped against a domain [0, N]: from `stops` the `displayStops` are computed with + * some continuity enrichment and a remap against a [0, 100] domain to make the palette component work ok. + * + * These naming conventions would be useful to track the code flow in this feature as multiple transformations are happening + * for a single change. + */ + +export function CustomizablePalette({ + palettes, + activePalette, + setPalette, + dataBounds, +}: { + palettes: PaletteRegistry; + activePalette: PaletteOutput; + setPalette: (palette: PaletteOutput) => void; + dataBounds: { min: number; max: number }; +}) { + const isCurrentPaletteCustom = activePalette.params?.name === CUSTOM_PALETTE; + + const colorStopsToShow = roundStopValues( + getColorStops(palettes, activePalette?.params?.colorStops || [], activePalette, dataBounds) + ); + + return ( + <> +
+ + { + const isNewPaletteCustom = newPalette.name === CUSTOM_PALETTE; + const newParams: CustomPaletteParams = { + ...activePalette.params, + name: newPalette.name, + colorStops: undefined, + }; + + if (isNewPaletteCustom) { + newParams.colorStops = getColorStops(palettes, [], activePalette, dataBounds); + } + + newParams.stops = getPaletteStops(palettes, newParams, { + prevPalette: + isNewPaletteCustom || isCurrentPaletteCustom ? undefined : newPalette.name, + dataBounds, + }); + + setPalette({ + ...newPalette, + params: newParams, + }); + }} + showCustomPalette + showDynamicColorOnly + /> + + + ['continuity']) => + setPalette( + mergePaletteParams(activePalette, { + continuity, + }) + ) + } + /> + + + {i18n.translate('xpack.lens.table.dynamicColoring.rangeType.label', { + defaultMessage: 'Value type', + })}{' '} + + + } + display="rowCompressed" + > + { + const newRangeType = id.replace( + idPrefix, + '' + ) as RequiredPaletteParamTypes['rangeType']; + + const params: CustomPaletteParams = { rangeType: newRangeType }; + if (isCurrentPaletteCustom) { + const { min: newMin, max: newMax } = getDataMinMax(newRangeType, dataBounds); + const { min: oldMin, max: oldMax } = getDataMinMax( + activePalette.params?.rangeType, + dataBounds + ); + const newColorStops = remapStopsByNewInterval(colorStopsToShow, { + oldInterval: oldMax - oldMin, + newInterval: newMax - newMin, + newMin, + oldMin, + }); + const stops = getPaletteStops( + palettes, + { ...activePalette.params, colorStops: newColorStops, ...params }, + { dataBounds } + ); + params.colorStops = newColorStops; + params.stops = stops; + params.rangeMin = newColorStops[0].stop; + params.rangeMax = newColorStops[newColorStops.length - 1].stop; + } else { + params.stops = getPaletteStops( + palettes, + { ...activePalette.params, ...params }, + { prevPalette: activePalette.name, dataBounds } + ); + } + setPalette(mergePaletteParams(activePalette, params)); + }} + /> + + + { + const params: CustomPaletteParams = { reverse: !activePalette.params?.reverse }; + if (isCurrentPaletteCustom) { + params.colorStops = reversePalette(colorStopsToShow); + params.stops = getPaletteStops( + palettes, + { + ...(activePalette?.params || {}), + colorStops: params.colorStops, + }, + { dataBounds } + ); + } else { + params.stops = reversePalette( + activePalette?.params?.stops || + getPaletteStops( + palettes, + { ...activePalette.params, ...params }, + { prevPalette: activePalette.name, dataBounds } + ) + ); + } + setPalette(mergePaletteParams(activePalette, params)); + }} + > + + + + + + {i18n.translate('xpack.lens.table.dynamicColoring.reverse.label', { + defaultMessage: 'Reverse colors', + })} + + + + + } + > + { + const newParams = getSwitchToCustomParams( + palettes, + activePalette, + { + colorStops, + steps: activePalette.params!.steps || DEFAULT_COLOR_STEPS, + rangeMin: colorStops[0]?.stop, + rangeMax: colorStops[colorStops.length - 1]?.stop, + }, + dataBounds + ); + return setPalette(newParams); + }} + /> + +
+ + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx new file mode 100644 index 00000000000000..164ed9bf067a66 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx @@ -0,0 +1,109 @@ +/* + * 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 React from 'react'; +import { EuiColorPalettePicker, EuiColorPalettePickerPaletteProps } from '@elastic/eui'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import { + CUSTOM_PALETTE, + DEFAULT_COLOR_STEPS, + FIXED_PROGRESSION, + defaultPaletteParams, +} from '../../shared_components/coloring/constants'; +import { CustomPaletteParams } from '../../shared_components/coloring/types'; +import { getStopsForFixedMode } from '../../shared_components/coloring/utils'; + +function getCustomPaletteConfig( + palettes: PaletteRegistry, + activePalette: PaletteOutput | undefined +) { + const { id, title } = palettes.get(CUSTOM_PALETTE); + + // Try to generate a palette from the current one + if (activePalette && activePalette.name !== CUSTOM_PALETTE) { + const currentPalette = palettes.get(activePalette.name); + if (currentPalette) { + const stops = currentPalette.getCategoricalColors(DEFAULT_COLOR_STEPS, activePalette?.params); + const palette = activePalette.params?.reverse ? stops.reverse() : stops; + return { + value: id, + title, + type: FIXED_PROGRESSION, + palette, + 'data-test-subj': `custom-palette`, + }; + } + } + // if not possible just show some text + if (!activePalette?.params?.stops) { + return { value: id, title, type: 'text' as const, 'data-test-subj': `custom-palette` }; + } + + // full custom palette + return { + value: id, + title, + type: FIXED_PROGRESSION, + 'data-test-subj': `custom-palette`, + palette: getStopsForFixedMode(activePalette.params.stops, activePalette.params.colorStops), + }; +} + +// Note: this is a special palette picker different from the one in the root shared folder +// ideally these should be merged together, but as for now this holds some custom logic hard to remove +export function PalettePicker({ + palettes, + activePalette, + setPalette, + showCustomPalette, + showDynamicColorOnly, + ...rest +}: { + palettes: PaletteRegistry; + activePalette?: PaletteOutput; + setPalette: (palette: PaletteOutput) => void; + showCustomPalette?: boolean; + showDynamicColorOnly?: boolean; +}) { + const palettesToShow: EuiColorPalettePickerPaletteProps[] = palettes + .getAll() + .filter(({ internal, canDynamicColoring }) => + showDynamicColorOnly ? canDynamicColoring : !internal + ) + .map(({ id, title, getCategoricalColors }) => { + const colors = getCategoricalColors( + DEFAULT_COLOR_STEPS, + id === activePalette?.name ? activePalette?.params : undefined + ); + return { + value: id, + title, + type: FIXED_PROGRESSION, + palette: activePalette?.params?.reverse ? colors.reverse() : colors, + 'data-test-subj': `${id}-palette`, + }; + }); + if (showCustomPalette) { + palettesToShow.push(getCustomPaletteConfig(palettes, activePalette)); + } + return ( + { + setPalette({ + type: 'palette', + name: newPalette, + }); + }} + valueOfSelected={activePalette?.name || defaultPaletteParams.name} + selectionDisplay="palette" + {...rest} + /> + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/types.ts b/x-pack/plugins/lens/public/shared_components/coloring/types.ts new file mode 100644 index 00000000000000..d9a8edf0ccb62b --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/types.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ +export interface ColorStop { + color: string; + stop: number; +} + +export interface CustomPaletteParams { + name?: string; + reverse?: boolean; + rangeType?: 'number' | 'percent'; + continuity?: 'above' | 'below' | 'all' | 'none'; + progression?: 'fixed'; + rangeMin?: number; + rangeMax?: number; + stops?: ColorStop[]; + colorStops?: ColorStop[]; + steps?: number; +} + +export type RequiredPaletteParamTypes = Required; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts new file mode 100644 index 00000000000000..8aaab0923584d8 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts @@ -0,0 +1,399 @@ +/* + * 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 { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { + applyPaletteParams, + getContrastColor, + getDataMinMax, + getPaletteStops, + getStepValue, + isValidColor, + mergePaletteParams, + remapStopsByNewInterval, + reversePalette, + roundStopValues, +} from './utils'; + +describe('applyPaletteParams', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + it('should return a palette stops array only by the name', () => { + expect( + applyPaletteParams( + paletteRegistry, + { name: 'default', type: 'palette', params: { name: 'default' } }, + { min: 0, max: 100 } + ) + ).toEqual([ + // stops are 0 and 50 by with a 20 offset (100 divided by 5 steps) for display + // the mock palette service has only 2 colors so tests are a bit off by that + { color: 'red', stop: 20 }, + { color: 'black', stop: 70 }, + ]); + }); + + it('should return a palette stops array reversed', () => { + expect( + applyPaletteParams( + paletteRegistry, + { name: 'default', type: 'palette', params: { name: 'default', reverse: true } }, + { min: 0, max: 100 } + ) + ).toEqual([ + { color: 'black', stop: 20 }, + { color: 'red', stop: 70 }, + ]); + }); +}); + +describe('remapStopsByNewInterval', () => { + it('should correctly remap the current palette from 0..1 to 0...100', () => { + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'red', stop: 0.9 }, + ], + { newInterval: 100, oldInterval: 1, newMin: 0, oldMin: 0 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 90 }, + ]); + + // now test the other way around + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 90 }, + ], + { newInterval: 1, oldInterval: 100, newMin: 0, oldMin: 0 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'red', stop: 0.9 }, + ]); + }); + + it('should correctly handle negative numbers to/from', () => { + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: -100 }, + { color: 'green', stop: -50 }, + { color: 'red', stop: -1 }, + ], + { newInterval: 100, oldInterval: 100, newMin: 0, oldMin: -100 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 99 }, + ]); + + // now map the other way around + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 99 }, + ], + { newInterval: 100, oldInterval: 100, newMin: -100, oldMin: 0 } + ) + ).toEqual([ + { color: 'black', stop: -100 }, + { color: 'green', stop: -50 }, + { color: 'red', stop: -1 }, + ]); + + // and test also palettes that also contains negative values + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: -50 }, + { color: 'green', stop: 0 }, + { color: 'red', stop: 50 }, + ], + { newInterval: 100, oldInterval: 100, newMin: 0, oldMin: -50 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 100 }, + ]); + }); +}); + +describe('getDataMinMax', () => { + it('should pick the correct min/max based on the current range type', () => { + expect(getDataMinMax('percent', { min: -100, max: 0 })).toEqual({ min: 0, max: 100 }); + }); + + it('should pick the correct min/max apply percent by default', () => { + expect(getDataMinMax(undefined, { min: -100, max: 0 })).toEqual({ min: 0, max: 100 }); + }); +}); + +describe('getPaletteStops', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + it('should correctly compute a predefined palette stops definition from only the name', () => { + expect( + getPaletteStops(paletteRegistry, { name: 'mock' }, { dataBounds: { min: 0, max: 100 } }) + ).toEqual([ + { color: 'blue', stop: 20 }, + { color: 'yellow', stop: 70 }, + ]); + }); + + it('should correctly compute a predefined palette stops definition from explicit prevPalette (override)', () => { + expect( + getPaletteStops( + paletteRegistry, + { name: 'default' }, + { dataBounds: { min: 0, max: 100 }, prevPalette: 'mock' } + ) + ).toEqual([ + { color: 'blue', stop: 20 }, + { color: 'yellow', stop: 70 }, + ]); + }); + + it('should infer the domain from dataBounds but start from 0', () => { + expect( + getPaletteStops( + paletteRegistry, + { name: 'default', rangeType: 'number' }, + { dataBounds: { min: 1, max: 11 }, prevPalette: 'mock' } + ) + ).toEqual([ + { color: 'blue', stop: 2 }, + { color: 'yellow', stop: 7 }, + ]); + }); + + it('should override the minStop when requested', () => { + expect( + getPaletteStops( + paletteRegistry, + { name: 'default', rangeType: 'number' }, + { dataBounds: { min: 1, max: 11 }, mapFromMinValue: true } + ) + ).toEqual([ + { color: 'red', stop: 1 }, + { color: 'black', stop: 6 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + rangeType: 'number', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 40 }, + { color: 'red', stop: 80 }, + ], + }, + { dataBounds: { min: 0, max: 100 } } + ) + ).toEqual([ + { color: 'green', stop: 40 }, + { color: 'blue', stop: 80 }, + { color: 'red', stop: 100 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user - handle stop at the end', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + rangeType: 'number', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 40 }, + { color: 'red', stop: 100 }, + ], + }, + { dataBounds: { min: 0, max: 100 } } + ) + ).toEqual([ + { color: 'green', stop: 40 }, + { color: 'blue', stop: 100 }, + { color: 'red', stop: 101 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user - handle stop at the end (fractional)', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + rangeType: 'number', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 0.4 }, + { color: 'red', stop: 1 }, + ], + }, + { dataBounds: { min: 0, max: 1 } } + ) + ).toEqual([ + { color: 'green', stop: 0.4 }, + { color: 'blue', stop: 1 }, + { color: 'red', stop: 2 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user - stretch the stops to 100% percent', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 0.4 }, + { color: 'red', stop: 1 }, + ], + }, + { dataBounds: { min: 0, max: 1 } } + ) + ).toEqual([ + { color: 'green', stop: 0.4 }, + { color: 'blue', stop: 1 }, + { color: 'red', stop: 100 }, // default rangeType is percent, hence stretch to 100% + ]); + }); +}); + +describe('reversePalette', () => { + it('should correctly reverse color and stops', () => { + expect( + reversePalette([ + { color: 'red', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'blue', stop: 0.9 }, + ]) + ).toEqual([ + { color: 'blue', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'red', stop: 0.9 }, + ]); + }); +}); + +describe('mergePaletteParams', () => { + it('should return a full palette', () => { + expect(mergePaletteParams({ type: 'palette', name: 'myPalette' }, { reverse: true })).toEqual({ + type: 'palette', + name: 'myPalette', + params: { reverse: true }, + }); + }); +}); + +describe('isValidColor', () => { + it('should return ok for valid hex color notation', () => { + expect(isValidColor('#fff')).toBe(true); + expect(isValidColor('#ffffff')).toBe(true); + expect(isValidColor('#ffffffaa')).toBe(true); + }); + + it('should return false for non valid strings', () => { + expect(isValidColor('')).toBe(false); + expect(isValidColor('#')).toBe(false); + expect(isValidColor('#ff')).toBe(false); + expect(isValidColor('123')).toBe(false); + expect(isValidColor('rgb(1, 1, 1)')).toBe(false); + expect(isValidColor('rgba(1, 1, 1, 0)')).toBe(false); + expect(isValidColor('#ffffffgg')).toBe(false); + expect(isValidColor('#fff00')).toBe(false); + // this version of chroma does not support hex4 format + expect(isValidColor('#fffa')).toBe(false); + }); +}); + +describe('roundStopValues', () => { + it('should round very long values', () => { + expect(roundStopValues([{ color: 'red', stop: 0.1515 }])).toEqual([ + { color: 'red', stop: 0.15 }, + ]); + }); +}); + +describe('getStepValue', () => { + it('should compute the next step based on the last 2 stops', () => { + expect( + getStepValue( + // first arg is taken as max reference + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + 100 + ) + ).toBe(50); + + expect( + getStepValue( + // first arg is taken as max reference + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 80 }, + ], + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + 90 + ) + ).toBe(10); // 90 - 80 + + expect( + getStepValue( + // first arg is taken as max reference + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 100 }, + ], + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + 100 + ) + ).toBe(1); + }); +}); + +describe('getContrastColor', () => { + it('should pick the light color when the passed one is dark', () => { + expect(getContrastColor('#000', true)).toBe('#ffffff'); + expect(getContrastColor('#000', false)).toBe('#ffffff'); + }); + + it('should pick the dark color when the passed one is light', () => { + expect(getContrastColor('#fff', true)).toBe('#000000'); + expect(getContrastColor('#fff', false)).toBe('#000000'); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts new file mode 100644 index 00000000000000..89fceec533493f --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts @@ -0,0 +1,308 @@ +/* + * 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 chroma from 'chroma-js'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import { euiLightVars, euiDarkVars } from '@kbn/ui-shared-deps/theme'; +import { isColorDark } from '@elastic/eui'; +import { + CUSTOM_PALETTE, + defaultPaletteParams, + DEFAULT_COLOR_STEPS, + DEFAULT_MAX_STOP, + DEFAULT_MIN_STOP, +} from './constants'; +import { CustomPaletteParams, ColorStop } from './types'; + +/** + * Some name conventions here: + * * `displayStops` => It's an additional transformation of `stops` into a [0, N] domain for the EUIPaletteDisplay component. + * * `stops` => final steps used to table coloring. It is a rightShift of the colorStops + * * `colorStops` => user's color stop inputs. Used to compute range min. + * + * When the user inputs the colorStops, they are designed to be the initial part of the color segment, + * so the next stops indicate where the previous stop ends. + * Both table coloring logic and EuiPaletteDisplay format implementation works differently than our current `colorStops`, + * by having the stop values at the end of each color segment rather than at the beginning: `stops` values are computed by a rightShift of `colorStops`. + * EuiPaletteDisplay has an additional requirement as it is always mapped against a domain [0, N]: from `stops` the `displayStops` are computed with + * some continuity enrichment and a remap against a [0, 100] domain to make the palette component work ok. + * + * These naming conventions would be useful to track the code flow in this feature as multiple transformations are happening + * for a single change. + */ + +export function applyPaletteParams( + palettes: PaletteRegistry, + activePalette: PaletteOutput, + dataBounds: { min: number; max: number } +) { + // make a copy of it as they have to be manipulated later on + let displayStops = getPaletteStops(palettes, activePalette?.params || {}, { + dataBounds, + }); + + if (activePalette?.params?.reverse && activePalette?.params?.name !== CUSTOM_PALETTE) { + displayStops = reversePalette(displayStops); + } + return displayStops; +} + +// Need to shift the Custom palette in order to correctly visualize it when in display mode +function shiftPalette(stops: ColorStop[], max: number) { + // shift everything right and add an additional stop at the end + const result = stops.map((entry, i, array) => ({ + ...entry, + stop: i + 1 < array.length ? array[i + 1].stop : max, + })); + if (stops[stops.length - 1].stop === max) { + // extends the range by a fair amount to make it work the extra case for the last stop === max + const computedStep = getStepValue(stops, result, max) || 1; + // do not go beyond the unit step in this case + const step = Math.min(1, computedStep); + result[stops.length - 1].stop = max + step; + } + return result; +} + +// Utility to remap color stops within new domain +export function remapStopsByNewInterval( + controlStops: ColorStop[], + { + newInterval, + oldInterval, + newMin, + oldMin, + }: { newInterval: number; oldInterval: number; newMin: number; oldMin: number } +) { + return (controlStops || []).map(({ color, stop }) => { + return { + color, + stop: newMin + ((stop - oldMin) * newInterval) / oldInterval, + }; + }); +} + +function getOverallMinMax( + params: CustomPaletteParams | undefined, + dataBounds: { min: number; max: number } +) { + const { min: dataMin, max: dataMax } = getDataMinMax(params?.rangeType, dataBounds); + const minStopValue = params?.colorStops?.[0]?.stop ?? Infinity; + const maxStopValue = params?.colorStops?.[params.colorStops.length - 1]?.stop ?? -Infinity; + const overallMin = Math.min(dataMin, minStopValue); + const overallMax = Math.max(dataMax, maxStopValue); + return { min: overallMin, max: overallMax }; +} + +export function getDataMinMax( + rangeType: CustomPaletteParams['rangeType'] | undefined, + dataBounds: { min: number; max: number } +) { + const dataMin = rangeType === 'number' ? dataBounds.min : DEFAULT_MIN_STOP; + const dataMax = rangeType === 'number' ? dataBounds.max : DEFAULT_MAX_STOP; + return { min: dataMin, max: dataMax }; +} + +/** + * This is a generic function to compute stops from the current parameters. + */ +export function getPaletteStops( + palettes: PaletteRegistry, + activePaletteParams: CustomPaletteParams, + // used to customize color resolution + { + prevPalette, + dataBounds, + mapFromMinValue, + }: { prevPalette?: string; dataBounds: { min: number; max: number }; mapFromMinValue?: boolean } +) { + const { min: minValue, max: maxValue } = getOverallMinMax(activePaletteParams, dataBounds); + const interval = maxValue - minValue; + const { stops: currentStops, ...otherParams } = activePaletteParams || {}; + + if (activePaletteParams.name === 'custom' && activePaletteParams?.colorStops) { + // need to generate the palette from the existing controlStops + return shiftPalette(activePaletteParams.colorStops, maxValue); + } + // generate a palette from predefined ones and customize the domain + const colorStopsFromPredefined = palettes + .get(prevPalette || activePaletteParams?.name || defaultPaletteParams.name) + .getCategoricalColors(defaultPaletteParams.steps, otherParams); + + const newStopsMin = mapFromMinValue ? minValue : interval / defaultPaletteParams.steps; + + const stops = remapStopsByNewInterval( + colorStopsFromPredefined.map((color, index) => ({ color, stop: index })), + { + newInterval: interval, + oldInterval: colorStopsFromPredefined.length, + newMin: newStopsMin, + oldMin: 0, + } + ); + return stops; +} + +export function reversePalette(paletteColorRepresentation: ColorStop[] = []) { + const stops = paletteColorRepresentation.map(({ stop }) => stop); + return paletteColorRepresentation + .map(({ color }, i) => ({ + color, + stop: stops[paletteColorRepresentation.length - i - 1], + })) + .reverse(); +} + +export function mergePaletteParams( + activePalette: PaletteOutput, + newParams: CustomPaletteParams +): PaletteOutput { + return { + ...activePalette, + params: { + ...activePalette.params, + ...newParams, + }, + }; +} + +function isValidPonyfill(colorString: string) { + // we're using an old version of chroma without the valid function + try { + chroma(colorString); + return true; + } catch (e) { + return false; + } +} + +export function isValidColor(colorString: string) { + // chroma can handle also hex values with alpha channel/transparency + // chroma accepts also hex without #, so test for it + return colorString !== '' && /^#/.test(colorString) && isValidPonyfill(colorString); +} + +export function roundStopValues(colorStops: ColorStop[]) { + return colorStops.map(({ color, stop }) => { + const roundedStop = Number(stop.toFixed(2)); + return { color, stop: roundedStop }; + }); +} + +// very simple heuristic: pick last two stops and compute a new stop based on the same distance +// if the new stop is above max, then reduce the step to reach max, or if zero then just 1. +// +// it accepts two series of stops as the function is used also when computing stops from colorStops +export function getStepValue(colorStops: ColorStop[], newColorStops: ColorStop[], max: number) { + const length = newColorStops.length; + // workout the steps from the last 2 items + const dataStep = newColorStops[length - 1].stop - newColorStops[length - 2].stop || 1; + let step = Number(dataStep.toFixed(2)); + if (max < colorStops[length - 1].stop + step) { + const diffToMax = max - colorStops[length - 1].stop; + // if the computed step goes way out of bound, fallback to 1, otherwise reach max + step = diffToMax > 0 ? diffToMax : 1; + } + return step; +} + +export function getSwitchToCustomParams( + palettes: PaletteRegistry, + activePalette: PaletteOutput, + newParams: CustomPaletteParams, + dataBounds: { min: number; max: number } +) { + // if it's already a custom palette just return the params + if (activePalette?.params?.name === CUSTOM_PALETTE) { + const stops = getPaletteStops( + palettes, + { + steps: DEFAULT_COLOR_STEPS, + ...activePalette.params, + ...newParams, + }, + { + dataBounds, + } + ); + return mergePaletteParams(activePalette, { + ...newParams, + stops, + }); + } + // prepare everything to switch to custom palette + const newPaletteParams = { + steps: DEFAULT_COLOR_STEPS, + ...activePalette.params, + ...newParams, + name: CUSTOM_PALETTE, + }; + + const stops = getPaletteStops(palettes, newPaletteParams, { + prevPalette: newPaletteParams.colorStops ? undefined : activePalette.name, + dataBounds, + }); + return mergePaletteParams( + { name: CUSTOM_PALETTE, type: 'palette' }, + { + ...newPaletteParams, + stops, + } + ); +} + +export function getColorStops( + palettes: PaletteRegistry, + colorStops: Required['stops'], + activePalette: PaletteOutput, + dataBounds: { min: number; max: number } +) { + // just forward the current stops if custom + if (activePalette?.name === CUSTOM_PALETTE) { + return colorStops; + } + // for predefined palettes create some stops, then drop the last one. + // we're using these as starting point for the user + let freshColorStops = getPaletteStops( + palettes, + { ...activePalette?.params }, + // mapFromMinValue is a special flag to offset the stops values + // used here to avoid a new remap/left shift + { dataBounds, mapFromMinValue: true } + ); + if (activePalette?.params?.reverse) { + freshColorStops = reversePalette(freshColorStops); + } + return freshColorStops; +} + +export function getContrastColor(color: string, isDarkTheme: boolean) { + const darkColor = isDarkTheme ? euiDarkVars.euiColorInk : euiLightVars.euiColorInk; + const lightColor = isDarkTheme ? euiDarkVars.euiColorGhost : euiLightVars.euiColorGhost; + return isColorDark(...chroma(color).rgb()) ? lightColor : darkColor; +} + +/** + * Same as stops, but remapped against a range 0-100 + */ +export function getStopsForFixedMode(stops: ColorStop[], colorStops?: ColorStop[]) { + const referenceStops = + colorStops || stops.map(({ color }, index) => ({ color, stop: 20 * index })); + const fallbackStops = stops; + + // what happens when user set two stops with the same value? we'll fallback to the display interval + const oldInterval = + referenceStops[referenceStops.length - 1].stop - referenceStops[0].stop || + fallbackStops[fallbackStops.length - 1].stop - fallbackStops[0].stop; + + return remapStopsByNewInterval(stops, { + newInterval: 100, + oldInterval, + newMin: 0, + oldMin: referenceStops[0].stop, + }); +} diff --git a/x-pack/plugins/lens/public/shared_components/helpers.ts b/x-pack/plugins/lens/public/shared_components/helpers.ts new file mode 100644 index 00000000000000..a9f35757c4cbfe --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/helpers.ts @@ -0,0 +1,31 @@ +/* + * 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 { useRef } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; + +export const useDebounceWithOptions = ( + fn: Function, + { skipFirstRender }: { skipFirstRender: boolean } = { skipFirstRender: false }, + ms?: number | undefined, + deps?: React.DependencyList | undefined +) => { + const isFirstRender = useRef(true); + const newDeps = [...(deps || []), isFirstRender]; + + return useDebounce( + () => { + if (skipFirstRender && isFirstRender.current) { + isFirstRender.current = false; + return; + } + return fn(); + }, + ms, + newDeps + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index ae57da976a8817..cf8536884acdf8 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -9,4 +9,7 @@ export * from './empty_placeholder'; export { ToolbarPopoverProps, ToolbarPopover } from './toolbar_popover'; export { LegendSettingsPopover } from './legend_settings_popover'; export { PalettePicker } from './palette_picker'; +export { TooltipWrapper } from './tooltip_wrapper'; +export * from './coloring'; export { useDebouncedValue } from './debounced_value'; +export * from './helpers'; diff --git a/x-pack/plugins/lens/public/shared_components/palette_picker.tsx b/x-pack/plugins/lens/public/shared_components/palette_picker.tsx index b15a6749d4c2d1..6424dc8143f957 100644 --- a/x-pack/plugins/lens/public/shared_components/palette_picker.tsx +++ b/x-pack/plugins/lens/public/shared_components/palette_picker.tsx @@ -7,10 +7,9 @@ import React from 'react'; import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; -import { EuiColorPalettePicker } from '@elastic/eui'; +import { EuiColorPalettePicker, EuiColorPalettePickerPaletteProps } from '@elastic/eui'; import { EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { NativeRenderer } from '../native_renderer'; export function PalettePicker({ palettes, @@ -21,6 +20,20 @@ export function PalettePicker({ activePalette?: PaletteOutput; setPalette: (palette: PaletteOutput) => void; }) { + const palettesToShow: EuiColorPalettePickerPaletteProps[] = palettes + .getAll() + .filter(({ internal }) => !internal) + .map(({ id, title, getCategoricalColors }) => { + return { + value: id, + title, + type: 'fixed', + palette: getCategoricalColors( + 10, + id === activePalette?.name ? activePalette?.params : undefined + ), + }; + }); return ( !internal) - .map(({ id, title, getColors }) => { - return { - value: id, - title, - type: 'fixed', - palette: getColors( - 10, - id === activePalette?.name ? activePalette?.params : undefined - ), - }; - })} + palettes={palettesToShow} onChange={(newPalette) => { setPalette({ type: 'palette', @@ -56,21 +56,6 @@ export function PalettePicker({ valueOfSelected={activePalette?.name || 'default'} selectionDisplay={'palette'} /> - {activePalette && palettes.get(activePalette.name).renderEditor && ( - { - setPalette({ - type: 'palette', - name: activePalette.name, - params: updater(activePalette.params), - }); - }, - }} - /> - )} ); diff --git a/x-pack/plugins/lens/public/xy_visualization/tooltip_wrapper.tsx b/x-pack/plugins/lens/public/shared_components/tooltip_wrapper.tsx similarity index 100% rename from x-pack/plugins/lens/public/xy_visualization/tooltip_wrapper.tsx rename to x-pack/plugins/lens/public/shared_components/tooltip_wrapper.tsx diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 5a632e03f8f36c..984fbf5555949b 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -9,6 +9,7 @@ import { IconType } from '@elastic/eui/src/components/icon/icon'; import { CoreSetup } from 'kibana/public'; import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; import { SavedObjectReference } from 'kibana/public'; +import { MutableRefObject } from 'react'; import { RowClickContext } from '../../../../src/plugins/ui_actions/public'; import { ExpressionAstExpression, @@ -391,13 +392,14 @@ export type VisualizationDimensionEditorProps = VisualizationConfig groupId: string; accessor: string; setState: (newState: T) => void; + panelRef: MutableRefObject; }; export interface AccessorConfig { columnId: string; triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none' | 'invisible'; color?: string; - palette?: string[]; + palette?: string[] | Array<{ color: string; stop: number }>; } export type VisualizationDimensionGroupConfig = SharedDimensionProps & { diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index d2e87ece5b5ec8..ef0c350f209619 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -118,7 +118,7 @@ export function getAccessorColorConfig( ); const customColor = currentYConfig?.color || - paletteService.get(currentPalette.name).getColor( + paletteService.get(currentPalette.name).getCategoricalColor( [ { name: columnToLabel[accessor] || accessor, diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index e3b4565913ad87..608971d281981f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -798,7 +798,7 @@ export function XYChart({ ), }, ]; - return paletteService.get(palette.name).getColor( + return paletteService.get(palette.name).getCategoricalColor( seriesLayers, { maxDepth: 1, diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx index b07feb85892e53..843680e3f28ac6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx @@ -7,14 +7,13 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { ToolbarPopover } from '../../shared_components'; +import { ToolbarPopover, TooltipWrapper } from '../../shared_components'; import { MissingValuesOptions } from './missing_values_option'; import { LineCurveOption } from './line_curve_option'; import { FillOpacityOption } from './fill_opacity_option'; import { XYState } from '../types'; import { hasHistogramSeries } from '../state_helpers'; import { ValidLayer } from '../types'; -import { TooltipWrapper } from '../tooltip_wrapper'; import { FramePublicAPI } from '../../types'; function getValueLabelDisableReason({ diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index aa4b91b840db37..8fbc8e8b2ef7ab 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -481,12 +481,12 @@ describe('xy_visualization', () => { it('should query palette to fill in colors for other dimensions', () => { const palette = paletteServiceMock.get('default'); - (palette.getColor as jest.Mock).mockClear(); + (palette.getCategoricalColor as jest.Mock).mockClear(); const accessorConfig = callConfigAndFindYConfig({}, 'c'); expect(accessorConfig.triggerIcon).toEqual('color'); // black is the color returned from the palette mock expect(accessorConfig.color).toEqual('black'); - expect(palette.getColor).toHaveBeenCalledWith( + expect(palette.getCategoricalColor).toHaveBeenCalledWith( [ { name: 'c', @@ -505,9 +505,9 @@ describe('xy_visualization', () => { label: 'Overwritten label', }); const palette = paletteServiceMock.get('default'); - (palette.getColor as jest.Mock).mockClear(); + (palette.getCategoricalColor as jest.Mock).mockClear(); callConfigAndFindYConfig({}, 'c'); - expect(palette.getColor).toHaveBeenCalledWith( + expect(palette.getCategoricalColor).toHaveBeenCalledWith( [ expect.objectContaining({ name: 'Overwritten label', @@ -526,7 +526,7 @@ describe('xy_visualization', () => { }, 'c' ); - expect(palette.getColor).toHaveBeenCalled(); + expect(palette.getCategoricalColor).toHaveBeenCalled(); }); it('should not show any indicator as long as there is no data', () => { @@ -551,7 +551,7 @@ describe('xy_visualization', () => { it('should show current palette for break down by dimension', () => { const palette = paletteServiceMock.get('mock'); const customColors = ['yellow', 'green']; - (palette.getColors as jest.Mock).mockReturnValue(customColors); + (palette.getCategoricalColors as jest.Mock).mockReturnValue(customColors); const breakdownConfig = callConfigForBreakdownConfigs({ palette: { type: 'palette', name: 'mock', params: {} }, splitAccessor: 'd', @@ -570,9 +570,9 @@ describe('xy_visualization', () => { paletteGetter.mockReturnValue({ id: 'default', title: '', - getColors: jest.fn(), + getCategoricalColors: jest.fn(), toExpression: jest.fn(), - getColor: jest.fn().mockReturnValueOnce('blue').mockReturnValueOnce('green'), + getCategoricalColor: jest.fn().mockReturnValueOnce('blue').mockReturnValueOnce('green'), }); const yConfigs = callConfigForYConfigs({}); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 19cfcb1a60cc7c..fa9d46be11d686 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -235,7 +235,7 @@ export const getXyVisualization = ({ triggerIcon: 'colorBy', palette: paletteService .get(layer.palette?.name || 'default') - .getColors(10, layer.palette?.params), + .getCategoricalColors(10, layer.palette?.params), }, ] : [], diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 0bafbead7d5438..bc10236cf1977e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -260,6 +260,7 @@ describe('XY Config panels', () => { state={{ ...state, layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' }] }} formatFactory={jest.fn()} paletteService={chartPluginMock.createPaletteRegistry()} + panelRef={React.createRef()} /> ); @@ -283,6 +284,7 @@ describe('XY Config panels', () => { state={state} formatFactory={jest.fn()} paletteService={chartPluginMock.createPaletteRegistry()} + panelRef={React.createRef()} /> ); @@ -326,6 +328,7 @@ describe('XY Config panels', () => { }} formatFactory={jest.fn()} paletteService={chartPluginMock.createPaletteRegistry()} + panelRef={React.createRef()} /> ); @@ -365,6 +368,7 @@ describe('XY Config panels', () => { }} formatFactory={jest.fn()} paletteService={chartPluginMock.createPaletteRegistry()} + panelRef={React.createRef()} /> ); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index a6517894654ede..48f0cacf75938a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -41,9 +41,8 @@ import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_h import { trackUiEvent } from '../lens_ui_telemetry'; import { LegendSettingsPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; -import { TooltipWrapper } from './tooltip_wrapper'; import { getAxesConfiguration, GroupsConfiguration } from './axes_configuration'; -import { PalettePicker } from '../shared_components'; +import { PalettePicker, TooltipWrapper } from '../shared_components'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; import { getScaleType, getSortedAccessors } from './to_expression'; import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 1652e78d3d2cbf..4fce4c276c3360 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -112,7 +112,7 @@ export async function getChartsPaletteServiceGetColor(): Promise< const chartConfiguration = { syncColors: true }; return (value: string) => { const series = [{ name: value, rankAtDepth: 0, totalSeriesAtDepth: 1 }]; - const color = paletteDefinition.getColor(series, chartConfiguration); + const color = paletteDefinition.getCategoricalColor(series, chartConfiguration); return color ? color : '#3d3d3d'; }; } diff --git a/x-pack/test/accessibility/apps/lens.ts b/x-pack/test/accessibility/apps/lens.ts index a8d20ff56de088..682aa5a576f9e2 100644 --- a/x-pack/test/accessibility/apps/lens.ts +++ b/x-pack/test/accessibility/apps/lens.ts @@ -69,6 +69,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); + it('lens datatable with dynamic cell colouring', async () => { + await PageObjects.lens.openDimensionEditor('lnsDatatable_metrics > lns-dimensionTrigger'); + await PageObjects.lens.setTableDynamicColoring('cell'); + await a11y.testAppSnapshot(); + }); + + it('lens datatable with dynamic text colouring', async () => { + await PageObjects.lens.setTableDynamicColoring('text'); + await a11y.testAppSnapshot(); + }); + + it('lens datatable with palette panel open', async () => { + await PageObjects.lens.openTablePalettePanel(); + await a11y.testAppSnapshot(); + }); + + it('lens datatable with custom palette stops', async () => { + await PageObjects.lens.changePaletteTo('custom'); + await a11y.testAppSnapshot(); + await PageObjects.lens.closePaletteEditor(); + await PageObjects.lens.closeDimensionEditor(); + }); + it('lens metric chart', async () => { await PageObjects.lens.switchToVisualization('lnsMetric'); await a11y.testAppSnapshot(); diff --git a/x-pack/test/functional/apps/lens/table.ts b/x-pack/test/functional/apps/lens/table.ts index f0f3ce27f4c31c..f048bf47991f2f 100644 --- a/x-pack/test/functional/apps/lens/table.ts +++ b/x-pack/test/functional/apps/lens/table.ts @@ -13,6 +13,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const listingTable = getService('listingTable'); const find = getService('find'); const retry = getService('retry'); + const testSubjects = getService('testSubjects'); describe('lens datatable', () => { it('should able to sort a table by a column', async () => { @@ -93,5 +94,55 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); expect(await PageObjects.lens.getDatatableCellText(0, 2)).to.eql('17,246'); }); + + it('should show dynamic coloring feature for numeric columns', async () => { + await PageObjects.lens.openDimensionEditor('lnsDatatable_metrics > lns-dimensionTrigger'); + await PageObjects.lens.setTableDynamicColoring('text'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const styleObj = await PageObjects.lens.getDatatableCellStyle(0, 2); + expect(styleObj['background-color']).to.be(undefined); + expect(styleObj.color).to.be('rgb(133, 189, 177)'); + }); + + it('should allow to color cell background rather than text', async () => { + await PageObjects.lens.setTableDynamicColoring('cell'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const styleObj = await PageObjects.lens.getDatatableCellStyle(0, 2); + expect(styleObj['background-color']).to.be('rgb(133, 189, 177)'); + // should also set text color when in cell mode + expect(styleObj.color).to.be('rgb(0, 0, 0)'); + }); + + it('should open the palette panel to customize the palette look', async () => { + await PageObjects.lens.openTablePalettePanel(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.changePaletteTo('temperature'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const styleObj = await PageObjects.lens.getDatatableCellStyle(0, 2); + expect(styleObj['background-color']).to.be('rgb(235, 239, 245)'); + }); + + it('tweak the color stops numeric value', async () => { + await testSubjects.setValue('lnsDatatable_dynamicColoring_stop_value_0', '30', { + clearWithKeyboard: true, + }); + // when clicking on another row will trigger a sorting + update + await testSubjects.click('lnsDatatable_dynamicColoring_stop_value_1'); + await PageObjects.header.waitUntilLoadingHasFinished(); + // pick a cell without color as is below the range + const styleObj = await PageObjects.lens.getDatatableCellStyle(3, 3); + expect(styleObj['background-color']).to.be(undefined); + // should also set text color when in cell mode + expect(styleObj.color).to.be(undefined); + }); + + it('should allow the user to reverse the palette', async () => { + await testSubjects.click('lnsDatatable_dynamicColoring_reverse'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const styleObj = await PageObjects.lens.getDatatableCellStyle(1, 1); + expect(styleObj['background-color']).to.be('rgb(168, 191, 218)'); + // should also set text color when in cell mode + expect(styleObj.color).to.be('rgb(0, 0, 0)'); + }); }); } diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index f73440e331466e..b16944cd730606 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -126,8 +126,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont } if (opts.palette) { - await testSubjects.click('lns-palettePicker'); - await find.clickByCssSelector(`#${opts.palette}`); + await this.setPalette(opts.palette); } if (!opts.keepOpen) { @@ -671,6 +670,18 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return el.getVisibleText(); }, + async getDatatableCellStyle(rowIndex = 0, colIndex = 0) { + const el = await this.getDatatableCell(rowIndex, colIndex); + const styleString = await el.getAttribute('style'); + return styleString.split(';').reduce>((memo, cssLine) => { + const [prop, value] = cssLine.split(':'); + if (prop && value) { + memo[prop.trim()] = value.trim(); + } + return memo; + }, {}); + }, + async getDatatableHeader(index = 0) { return find.byCssSelector( `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridHeader"] [role=columnheader]:nth-child(${ @@ -714,6 +725,46 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return buttonEl.click(); }, + async setTableDynamicColoring(coloringType: 'none' | 'cell' | 'text') { + await testSubjects.click('lnsDatatable_dynamicColoring_groups_' + coloringType); + }, + + async openTablePalettePanel() { + await testSubjects.click('lnsDatatable_dynamicColoring_trigger'); + }, + + // different picker from the next one + async changePaletteTo(paletteName: string) { + await testSubjects.click('lnsDatatable_dynamicColoring_palette_picker'); + await testSubjects.click(`${paletteName}-palette`); + }, + + async setPalette(paletteName: string) { + await testSubjects.click('lns-palettePicker'); + await find.clickByCssSelector(`#${paletteName}`); + }, + + async closePaletteEditor() { + await retry.try(async () => { + await testSubjects.click('lns-indexPattern-PalettePanelContainerBack'); + await testSubjects.missingOrFail('lns-indexPattern-PalettePanelContainerBack'); + }); + }, + + async openColorStopPopup(index = 0) { + const stopEls = await testSubjects.findAll('euiColorStopThumb'); + if (stopEls[index]) { + await stopEls[index].click(); + } + }, + + async setColorStopValue(value: number | string) { + await testSubjects.setValue( + 'lnsDatatable_dynamicColoring_progression_custom_stops_value', + String(value) + ); + }, + async toggleColumnVisibility(dimension: string) { await this.openDimensionEditor(dimension); const id = 'lns-table-column-hidden'; From 8715de8c5ec98c91c58cd5597e0f1f7e91caf8e5 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Fri, 28 May 2021 16:39:38 +0300 Subject: [PATCH 6/9] [TSVB] Fix Upgrading from 7.12.1 to 7.13.0 breaks TSVB (#100864) Closes: #100778 --- .../common/index_patterns_utils.test.ts | 6 +-- .../common/index_patterns_utils.ts | 6 +-- .../application/components/index_pattern.js | 46 +++++++++++++++++-- .../index_pattern_select.tsx | 31 ++----------- .../server/lib/get_vis_data.ts | 28 +++++------ .../lib/cached_index_pattern_fetcher.ts | 9 +++- 6 files changed, 75 insertions(+), 51 deletions(-) diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts index 7828ee33736eec..a601da234e0782 100644 --- a/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts @@ -90,7 +90,7 @@ describe('fetchIndexPattern', () => { ] as IndexPattern[]; const value = await fetchIndexPattern('indexTitle', indexPatternsService, { - fetchKibabaIndexForStringIndexes: true, + fetchKibanaIndexForStringIndexes: true, }); expect(value).toMatchInlineSnapshot(` @@ -104,9 +104,9 @@ describe('fetchIndexPattern', () => { `); }); - test('should return only indexPatternString if Kibana index does not exist (fetchKibabaIndexForStringIndexes is true)', async () => { + test('should return only indexPatternString if Kibana index does not exist (fetchKibanaIndexForStringIndexes is true)', async () => { const value = await fetchIndexPattern('indexTitle', indexPatternsService, { - fetchKibabaIndexForStringIndexes: true, + fetchKibanaIndexForStringIndexes: true, }); expect(value).toMatchInlineSnapshot(` diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts index 152fd5182225be..1224fd33daee34 100644 --- a/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts @@ -51,9 +51,9 @@ export const fetchIndexPattern = async ( indexPatternValue: IndexPatternValue | undefined, indexPatternsService: Pick, options: { - fetchKibabaIndexForStringIndexes: boolean; + fetchKibanaIndexForStringIndexes: boolean; } = { - fetchKibabaIndexForStringIndexes: false, + fetchKibanaIndexForStringIndexes: false, } ): Promise => { let indexPattern: FetchedIndexPattern['indexPattern']; @@ -63,7 +63,7 @@ export const fetchIndexPattern = async ( indexPattern = await indexPatternsService.getDefault(); } else { if (isStringTypeIndexPattern(indexPatternValue)) { - if (options.fetchKibabaIndexForStringIndexes) { + if (options.fetchKibanaIndexForStringIndexes) { indexPattern = (await indexPatternsService.find(indexPatternValue)).find( (index) => index.title === indexPatternValue ); diff --git a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js index bc2d9124e9c4a6..7d18af2bd0d59c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js +++ b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js @@ -8,7 +8,7 @@ import { get } from 'lodash'; import PropTypes from 'prop-types'; -import React, { useContext, useCallback, useEffect } from 'react'; +import React, { useContext, useCallback, useEffect, useState } from 'react'; import { htmlIdGenerator, EuiFieldText, @@ -29,15 +29,17 @@ import { LastValueModePopover } from './last_value_mode_popover'; import { KBN_FIELD_TYPES } from '../../../../data/public'; import { FormValidationContext } from '../contexts/form_validation_context'; import { DefaultIndexPatternContext } from '../contexts/default_index_context'; +import { PanelModelContext } from '../contexts/panel_model_context'; import { isGteInterval, validateReInterval, isAutoInterval } from './lib/get_interval'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { PANEL_TYPES, TIME_RANGE_DATA_MODES, TIME_RANGE_MODE_KEY } from '../../../common/enums'; -import { AUTO_INTERVAL } from '../../../common/constants'; +import { AUTO_INTERVAL, USE_KIBANA_INDEXES_KEY } from '../../../common/constants'; import { isTimerangeModeEnabled } from '../lib/check_ui_restrictions'; import { VisDataContext } from '../contexts/vis_data_context'; -import { getUISettings } from '../../services'; +import { getDataStart, getUISettings } from '../../services'; import { UI_SETTINGS } from '../../../../data/common'; +import { fetchIndexPattern } from '../../../common/index_patterns_utils'; const RESTRICT_FIELDS = [KBN_FIELD_TYPES.DATE]; const LEVEL_OF_DETAIL_STEPS = 10; @@ -77,8 +79,13 @@ export const IndexPattern = ({ const dropBucketName = `${prefix}drop_last_bucket`; const updateControlValidity = useContext(FormValidationContext); const defaultIndex = useContext(DefaultIndexPatternContext); + const panelModel = useContext(PanelModelContext); + const uiRestrictions = get(useContext(VisDataContext), 'uiRestrictions'); const maxBarsUiSettings = config.get(UI_SETTINGS.HISTOGRAM_MAX_BARS); + const useKibanaIndices = Boolean(panelModel?.[USE_KIBANA_INDEXES_KEY]); + + const [fetchedIndex, setFetchedIndex] = useState(); const handleMaxBarsChange = useCallback( ({ target }) => { @@ -118,6 +125,7 @@ export const IndexPattern = ({ }; const model = { ...defaults, ..._model }; + const index = model[indexPatternName]; const intervalValidation = validateIntervalValue(model[intervalName]); const selectedTimeRangeOption = timeRangeOptions.find( @@ -133,11 +141,40 @@ export const IndexPattern = ({ updateControlValidity(intervalName, intervalValidation.isValid); }, [intervalName, intervalValidation.isValid, updateControlValidity]); + useEffect(() => { + async function fetchIndex() { + const { indexPatterns } = getDataStart(); + + setFetchedIndex( + index + ? await fetchIndexPattern(index, indexPatterns, { + fetchKibanaIndexForStringIndexes: true, + }) + : { + indexPattern: undefined, + indexPatternString: undefined, + } + ); + } + + fetchIndex(); + }, [index]); + const toggleIndicatorDisplay = useCallback( () => onChange({ [HIDE_LAST_VALUE_INDICATOR]: !model.hide_last_value_indicator }), [model.hide_last_value_indicator, onChange] ); + const getTimefieldPlaceholder = () => { + if (!model[indexPatternName]) { + return defaultIndex?.timeFieldName; + } + + if (useKibanaIndices) { + return fetchedIndex?.indexPattern?.timeFieldName ?? undefined; + } + }; + return (
{!isTimeSeries && ( @@ -207,6 +244,7 @@ export const IndexPattern = ({ diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx index ece90d47993093..07edfc2e6e0d70 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx @@ -6,18 +6,15 @@ * Side Public License, v 1. */ -import React, { useState, useContext, useCallback, useEffect } from 'react'; +import React, { useContext, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFormRow, EuiText, EuiLink, htmlIdGenerator } from '@elastic/eui'; -import { getCoreStart, getDataStart } from '../../../../services'; +import { getCoreStart } from '../../../../services'; import { PanelModelContext } from '../../../contexts/panel_model_context'; -import { - isStringTypeIndexPattern, - fetchIndexPattern, -} from '../../../../../common/index_patterns_utils'; +import { isStringTypeIndexPattern } from '../../../../../common/index_patterns_utils'; import { FieldTextSelect } from './field_text_select'; import { ComboBoxSelect } from './combo_box_select'; @@ -32,6 +29,7 @@ interface IndexPatternSelectProps { onChange: Function; disabled?: boolean; allowIndexSwitchingMode?: boolean; + fetchedIndex: FetchedIndexPattern | null; } const defaultIndexPatternHelpText = i18n.translate( @@ -57,13 +55,13 @@ export const IndexPatternSelect = ({ indexPatternName, onChange, disabled, + fetchedIndex, allowIndexSwitchingMode, }: IndexPatternSelectProps) => { const htmlId = htmlIdGenerator(); const panelModel = useContext(PanelModelContext); const defaultIndex = useContext(DefaultIndexPatternContext); - const [fetchedIndex, setFetchedIndex] = useState(); const useKibanaIndices = Boolean(panelModel?.[USE_KIBANA_INDEXES_KEY]); const Component = useKibanaIndices ? ComboBoxSelect : FieldTextSelect; @@ -98,25 +96,6 @@ export const IndexPatternSelect = ({ }); }, [fetchedIndex]); - useEffect(() => { - async function fetchIndex() { - const { indexPatterns } = getDataStart(); - - setFetchedIndex( - value - ? await fetchIndexPattern(value, indexPatterns, { - fetchKibabaIndexForStringIndexes: true, - }) - : { - indexPattern: undefined, - indexPatternString: undefined, - } - ); - } - - fetchIndex(); - }, [value]); - if (!fetchedIndex) { return null; } diff --git a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts index cb105d7b439ccf..5cdea62af95361 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts @@ -31,20 +31,22 @@ export async function getVisData( const indexPatternsService = await framework.getIndexPatternsService(requestContext); const esQueryConfig = await getEsQueryConfig(uiSettings); - const services: VisTypeTimeseriesRequestServices = { - esQueryConfig, - esShardTimeout, - indexPatternsService, - uiSettings, - searchStrategyRegistry: framework.searchStrategyRegistry, - cachedIndexPatternFetcher: getCachedIndexPatternFetcher(indexPatternsService), - }; - const promises = request.body.panels.map((panel) => { - if (panel.type === PANEL_TYPES.TABLE) { - return getTableData(requestContext, request, panel, services); - } - return getSeriesData(requestContext, request, panel, services); + const services: VisTypeTimeseriesRequestServices = { + esQueryConfig, + esShardTimeout, + indexPatternsService, + uiSettings, + searchStrategyRegistry: framework.searchStrategyRegistry, + cachedIndexPatternFetcher: getCachedIndexPatternFetcher( + indexPatternsService, + Boolean(panel.use_kibana_indexes) + ), + }; + + return panel.type === PANEL_TYPES.TABLE + ? getTableData(requestContext, request, panel, services) + : getSeriesData(requestContext, request, panel, services); }); return Promise.all(promises).then((res) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts index b03fa973e9da99..26ea191ab92173 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts @@ -11,7 +11,10 @@ import { getIndexPatternKey, fetchIndexPattern } from '../../../../common/index_ import type { IndexPatternsService } from '../../../../../data/server'; import type { IndexPatternValue, FetchedIndexPattern } from '../../../../common/types'; -export const getCachedIndexPatternFetcher = (indexPatternsService: IndexPatternsService) => { +export const getCachedIndexPatternFetcher = ( + indexPatternsService: IndexPatternsService, + fetchKibanaIndexForStringIndexes: boolean = false +) => { const cache = new Map(); return async (indexPatternValue: IndexPatternValue): Promise => { @@ -21,7 +24,9 @@ export const getCachedIndexPatternFetcher = (indexPatternsService: IndexPatterns return cache.get(key); } - const fetchedIndex = fetchIndexPattern(indexPatternValue, indexPatternsService); + const fetchedIndex = fetchIndexPattern(indexPatternValue, indexPatternsService, { + fetchKibanaIndexForStringIndexes, + }); cache.set(key, fetchedIndex); From c0f9970a5553b293d7044f3f0ad3baeeaa090960 Mon Sep 17 00:00:00 2001 From: ymao1 Date: Fri, 28 May 2021 09:52:58 -0400 Subject: [PATCH 7/9] [Alerting] Adding feature flag for enabling/disabling rule import and export (#100718) * Adding feature flag for enabling rule import and export * Removing item from docs Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/user/alerting/rule-management.asciidoc | 9 -- x-pack/plugins/alerting/server/config.test.ts | 3 +- x-pack/plugins/alerting/server/config.ts | 1 + .../alerting/server/health/get_state.test.ts | 8 ++ x-pack/plugins/alerting/server/plugin.test.ts | 73 ++++++++++++ x-pack/plugins/alerting/server/plugin.ts | 2 +- .../alerting/server/saved_objects/index.ts | 104 ++++++++++-------- 7 files changed, 141 insertions(+), 59 deletions(-) diff --git a/docs/user/alerting/rule-management.asciidoc b/docs/user/alerting/rule-management.asciidoc index e47858f58cd1a6..b908bd03b09927 100644 --- a/docs/user/alerting/rule-management.asciidoc +++ b/docs/user/alerting/rule-management.asciidoc @@ -57,15 +57,6 @@ These operations can also be performed in bulk by multi-selecting rules and clic [role="screenshot"] image:images/bulk-mute-disable.png[The Manage rules button lets you mute/unmute, enable/disable, and delete in bulk] -[float] -[[importing-and-exporting-rules]] -=== Importing and exporting rules - -To import and export rules, use the <>. -After the succesful import the proper banner will be displayed: -[role="screenshot"] -image::images/rules-imported-banner.png[Rules import banner, width=50%] - [float] === Required permissions diff --git a/x-pack/plugins/alerting/server/config.test.ts b/x-pack/plugins/alerting/server/config.test.ts index 069c41605ccfbc..a8befe5210752e 100644 --- a/x-pack/plugins/alerting/server/config.test.ts +++ b/x-pack/plugins/alerting/server/config.test.ts @@ -8,10 +8,11 @@ import { configSchema } from './config'; describe('config validation', () => { - test('alerts defaults', () => { + test('alerting defaults', () => { const config: Record = {}; expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { + "enableImportExport": false, "healthCheck": Object { "interval": "60m", }, diff --git a/x-pack/plugins/alerting/server/config.ts b/x-pack/plugins/alerting/server/config.ts index e42955b385bf1e..d50917fd135785 100644 --- a/x-pack/plugins/alerting/server/config.ts +++ b/x-pack/plugins/alerting/server/config.ts @@ -16,6 +16,7 @@ export const configSchema = schema.object({ interval: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }), removalDelay: schema.string({ validate: validateDurationSchema, defaultValue: '1h' }), }), + enableImportExport: schema.boolean({ defaultValue: false }), }); export type AlertsConfig = TypeOf; diff --git a/x-pack/plugins/alerting/server/health/get_state.test.ts b/x-pack/plugins/alerting/server/health/get_state.test.ts index 643d966d1fad0b..96627e10fb3bdf 100644 --- a/x-pack/plugins/alerting/server/health/get_state.test.ts +++ b/x-pack/plugins/alerting/server/health/get_state.test.ts @@ -72,6 +72,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }), pollInterval ).subscribe(); @@ -107,6 +108,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }), pollInterval, retryDelay @@ -152,6 +154,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }) ).toPromise(); @@ -182,6 +185,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }) ).toPromise(); @@ -212,6 +216,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }) ).toPromise(); @@ -239,6 +244,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }), retryDelay ).subscribe((status) => { @@ -269,6 +275,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }), retryDelay ).subscribe((status) => { @@ -305,6 +312,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }) ).toPromise(); diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index ec4b7095d67f7f..4e9249944a6bf9 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -18,6 +18,7 @@ import { AlertsConfig } from './config'; import { AlertType } from './types'; import { eventLogMock } from '../../event_log/server/mocks'; import { actionsMock } from '../../actions/server/mocks'; +import mappings from './saved_objects/mappings.json'; describe('Alerting Plugin', () => { describe('setup()', () => { @@ -25,6 +26,8 @@ describe('Alerting Plugin', () => { let coreSetup: ReturnType; let pluginsSetup: jest.Mocked; + beforeEach(() => jest.clearAllMocks()); + it('should log warning when Encrypted Saved Objects plugin is missing encryption key', async () => { const context = coreMock.createPluginInitializerContext({ healthCheck: { @@ -34,6 +37,7 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }); plugin = new AlertingPlugin(context); @@ -57,6 +61,72 @@ describe('Alerting Plugin', () => { ); }); + it('should register saved object with no management capability if enableImportExport is false', async () => { + const context = coreMock.createPluginInitializerContext({ + healthCheck: { + interval: '5m', + }, + invalidateApiKeysTask: { + interval: '5m', + removalDelay: '1h', + }, + enableImportExport: false, + }); + plugin = new AlertingPlugin(context); + + const setupMocks = coreMock.createSetup(); + await plugin.setup(setupMocks, { + licensing: licensingMock.createSetup(), + encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(), + taskManager: taskManagerMock.createSetup(), + eventLog: eventLogServiceMock.create(), + actions: actionsMock.createSetup(), + statusService: statusServiceMock.createSetupContract(), + }); + + expect(setupMocks.savedObjects.registerType).toHaveBeenCalledTimes(2); + const registerAlertingSavedObject = setupMocks.savedObjects.registerType.mock.calls[0][0]; + expect(registerAlertingSavedObject.name).toEqual('alert'); + expect(registerAlertingSavedObject.hidden).toBe(true); + expect(registerAlertingSavedObject.mappings).toEqual(mappings.alert); + expect(registerAlertingSavedObject.management).toBeUndefined(); + }); + + it('should register saved object with import/export capability if enableImportExport is true', async () => { + const context = coreMock.createPluginInitializerContext({ + healthCheck: { + interval: '5m', + }, + invalidateApiKeysTask: { + interval: '5m', + removalDelay: '1h', + }, + enableImportExport: true, + }); + plugin = new AlertingPlugin(context); + + const setupMocks = coreMock.createSetup(); + await plugin.setup(setupMocks, { + licensing: licensingMock.createSetup(), + encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(), + taskManager: taskManagerMock.createSetup(), + eventLog: eventLogServiceMock.create(), + actions: actionsMock.createSetup(), + statusService: statusServiceMock.createSetupContract(), + }); + + expect(setupMocks.savedObjects.registerType).toHaveBeenCalledTimes(2); + const registerAlertingSavedObject = setupMocks.savedObjects.registerType.mock.calls[0][0]; + expect(registerAlertingSavedObject.name).toEqual('alert'); + expect(registerAlertingSavedObject.hidden).toBe(true); + expect(registerAlertingSavedObject.mappings).toEqual(mappings.alert); + expect(registerAlertingSavedObject.management).not.toBeUndefined(); + expect(registerAlertingSavedObject.management?.importableAndExportable).toBe(true); + expect(registerAlertingSavedObject.management?.getTitle).not.toBeUndefined(); + expect(registerAlertingSavedObject.management?.onImport).not.toBeUndefined(); + expect(registerAlertingSavedObject.management?.onExport).not.toBeUndefined(); + }); + describe('registerType()', () => { let setup: PluginSetupContract; const sampleAlertType: AlertType = { @@ -119,6 +189,7 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }); const plugin = new AlertingPlugin(context); @@ -158,6 +229,7 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }); const plugin = new AlertingPlugin(context); @@ -211,6 +283,7 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, + enableImportExport: false, }); const plugin = new AlertingPlugin(context); diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 990733c320dfe8..769243b8feaf6a 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -190,7 +190,7 @@ export class AlertingPlugin { event: { provider: EVENT_LOG_PROVIDER }, }); - setupSavedObjects(core.savedObjects, plugins.encryptedSavedObjects); + setupSavedObjects(core.savedObjects, plugins.encryptedSavedObjects, this.config); this.eventLogService = plugins.eventLog; plugins.eventLog.registerProviderActions(EVENT_LOG_PROVIDER, Object.values(EVENT_LOG_ACTIONS)); diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 6b76fd97dc53b7..c339183eeedcdb 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -16,6 +16,7 @@ import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objec import { transformRulesForExport } from './transform_rule_for_export'; import { RawAlert } from '../types'; import { getImportWarnings } from './get_import_warnings'; +import { AlertsConfig } from '../config'; export { partiallyUpdateAlert } from './partially_update_alert'; export const AlertAttributesExcludedFromAAD = [ @@ -41,59 +42,66 @@ export type AlertAttributesExcludedFromAADType = export function setupSavedObjects( savedObjects: SavedObjectsServiceSetup, - encryptedSavedObjects: EncryptedSavedObjectsPluginSetup + encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, + alertingConfig: Promise ) { - savedObjects.registerType({ - name: 'alert', - hidden: true, - namespaceType: 'single', - migrations: getMigrations(encryptedSavedObjects), - mappings: mappings.alert, - management: { - importableAndExportable: true, - getTitle(ruleSavedObject: SavedObject) { - return `Rule: [${ruleSavedObject.attributes.name}]`; - }, - onImport(ruleSavedObjects) { - return { - warnings: getImportWarnings(ruleSavedObjects), - }; - }, - onExport( - context: SavedObjectsExportTransformContext, - objects: Array> - ) { - return transformRulesForExport(objects); - }, - }, - }); + alertingConfig.then((config: AlertsConfig) => { + savedObjects.registerType({ + name: 'alert', + hidden: true, + namespaceType: 'single', + migrations: getMigrations(encryptedSavedObjects), + mappings: mappings.alert, + ...(config.enableImportExport + ? { + management: { + importableAndExportable: true, + getTitle(ruleSavedObject: SavedObject) { + return `Rule: [${ruleSavedObject.attributes.name}]`; + }, + onImport(ruleSavedObjects) { + return { + warnings: getImportWarnings(ruleSavedObjects), + }; + }, + onExport( + context: SavedObjectsExportTransformContext, + objects: Array> + ) { + return transformRulesForExport(objects); + }, + }, + } + : {}), + }); - savedObjects.registerType({ - name: 'api_key_pending_invalidation', - hidden: true, - namespaceType: 'agnostic', - mappings: { - properties: { - apiKeyId: { - type: 'keyword', - }, - createdAt: { - type: 'date', + savedObjects.registerType({ + name: 'api_key_pending_invalidation', + hidden: true, + namespaceType: 'agnostic', + mappings: { + properties: { + apiKeyId: { + type: 'keyword', + }, + createdAt: { + type: 'date', + }, }, }, - }, - }); + }); - // Encrypted attributes - encryptedSavedObjects.registerType({ - type: 'alert', - attributesToEncrypt: new Set(['apiKey']), - attributesToExcludeFromAAD: new Set(AlertAttributesExcludedFromAAD), - }); + // Encrypted attributes + encryptedSavedObjects.registerType({ + type: 'alert', + attributesToEncrypt: new Set(['apiKey']), + attributesToExcludeFromAAD: new Set(AlertAttributesExcludedFromAAD), + }); - // Encrypted attributes - encryptedSavedObjects.registerType({ - type: 'api_key_pending_invalidation', - attributesToEncrypt: new Set(['apiKeyId']), + // Encrypted attributes + encryptedSavedObjects.registerType({ + type: 'api_key_pending_invalidation', + attributesToEncrypt: new Set(['apiKeyId']), + }); }); } From b575a4545f35268e33904d3b625e3cb7990c3930 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 28 May 2021 15:02:44 +0100 Subject: [PATCH 8/9] chore(NA): moving @kbn/io-ts-utils into bazel (#100810) --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 1 + packages/kbn-io-ts-utils/BUILD.bazel | 85 +++++++++++++++++++ packages/kbn-io-ts-utils/package.json | 7 +- packages/kbn-io-ts-utils/tsconfig.json | 2 +- .../kbn-server-route-repository/package.json | 3 - yarn.lock | 2 +- 8 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 packages/kbn-io-ts-utils/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 4e8bbf76eaacb0..dbfbe90ec9263e 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -80,6 +80,7 @@ yarn kbn watch-bazel - @kbn/eslint-plugin-eslint - @kbn/expect - @kbn/i18n +- @kbn/io-ts-utils - @kbn/legacy-logging - @kbn/logging - @kbn/mapbox-gl diff --git a/package.json b/package.json index 627e8abd9d259c..f41c85c4c7b805 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl/npm_module", "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n/npm_module", "@kbn/interpreter": "link:packages/kbn-interpreter", - "@kbn/io-ts-utils": "link:packages/kbn-io-ts-utils", + "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils/npm_module", "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging/npm_module", "@kbn/logging": "link:bazel-bin/packages/kbn-logging/npm_module", "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco/npm_module", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index c885666f7a916e..de3498da1a6976 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -22,6 +22,7 @@ filegroup( "//packages/kbn-eslint-plugin-eslint:build", "//packages/kbn-expect:build", "//packages/kbn-i18n:build", + "//packages/kbn-io-ts-utils:build", "//packages/kbn-legacy-logging:build", "//packages/kbn-logging:build", "//packages/kbn-mapbox-gl:build", diff --git a/packages/kbn-io-ts-utils/BUILD.bazel b/packages/kbn-io-ts-utils/BUILD.bazel new file mode 100644 index 00000000000000..6b26173fe8f369 --- /dev/null +++ b/packages/kbn-io-ts-utils/BUILD.bazel @@ -0,0 +1,85 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-io-ts-utils" +PKG_REQUIRE_NAME = "@kbn/io-ts-utils" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +SRC_DEPS = [ + "@npm//fp-ts", + "@npm//io-ts", + "@npm//lodash", + "@npm//tslib", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/lodash", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-io-ts-utils/package.json b/packages/kbn-io-ts-utils/package.json index 4d6f02d3f85a62..9d22277f27c014 100644 --- a/packages/kbn-io-ts-utils/package.json +++ b/packages/kbn-io-ts-utils/package.json @@ -4,10 +4,5 @@ "types": "./target/index.d.ts", "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", - "private": true, - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - } + "private": true } diff --git a/packages/kbn-io-ts-utils/tsconfig.json b/packages/kbn-io-ts-utils/tsconfig.json index 6c67518e210734..7b8f2552754992 100644 --- a/packages/kbn-io-ts-utils/tsconfig.json +++ b/packages/kbn-io-ts-utils/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "./target", "stripInternal": false, "declaration": true, diff --git a/packages/kbn-server-route-repository/package.json b/packages/kbn-server-route-repository/package.json index ce1ca02d0c4f6b..4ae625d83a7003 100644 --- a/packages/kbn-server-route-repository/package.json +++ b/packages/kbn-server-route-repository/package.json @@ -9,8 +9,5 @@ "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" - }, - "dependencies": { - "@kbn/io-ts-utils": "link:../kbn-io-ts-utils" } } diff --git a/yarn.lock b/yarn.lock index a92dadf08dde74..ee4fadac018bc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2666,7 +2666,7 @@ version "0.0.0" uid "" -"@kbn/io-ts-utils@link:packages/kbn-io-ts-utils": +"@kbn/io-ts-utils@link:bazel-bin/packages/kbn-io-ts-utils/npm_module": version "0.0.0" uid "" From bd2bf74de837f98f42aabcb599990ab72fd38e97 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Fri, 28 May 2021 17:13:13 +0300 Subject: [PATCH 9/9] [TSVB] [Table tab] Fix "Math" aggregation (#100765) --- .../server/lib/vis_data/response_processors/table/math.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/table/math.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/table/math.js index fd7f5a06cac560..5abfc3e26ffcdd 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/table/math.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/table/math.js @@ -8,9 +8,9 @@ import { mathAgg } from '../series/math'; -export function math(bucket, panel, series) { +export function math(bucket, panel, series, meta, extractFields) { return (next) => (results) => { - const mathFn = mathAgg({ aggregations: bucket }, panel, series); + const mathFn = mathAgg({ aggregations: bucket }, panel, series, meta, extractFields); return mathFn(next)(results); }; }