diff --git a/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.test.tsx b/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.test.tsx new file mode 100644 index 0000000000000..26e9c18e00e9e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { debouncedComponent } from './debounced_component'; + +describe('debouncedComponent', () => { + test('immediately renders', () => { + const TestComponent = debouncedComponent(({ title }: { title: string }) => { + return

{title}

; + }); + expect(mount().html()).toMatchInlineSnapshot(`"

hoi

"`); + }); + + test('debounces changes', async () => { + const TestComponent = debouncedComponent(({ title }: { title: string }) => { + return

{title}

; + }, 1); + const component = mount(); + component.setProps({ title: 'yall' }); + expect(component.text()).toEqual('there'); + await new Promise(r => setTimeout(r, 1)); + expect(component.text()).toEqual('yall'); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.tsx b/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.tsx new file mode 100644 index 0000000000000..be6830c115836 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useMemo, memo, FunctionComponent } from 'react'; +import { debounce } from 'lodash'; + +/** + * debouncedComponent wraps the specified React component, returning a component which + * only renders once there is a pause in props changes for at least `delay` milliseconds. + * During the debounce phase, it will return the previously rendered value. + */ +export function debouncedComponent(component: FunctionComponent, delay = 256) { + const MemoizedComponent = (memo(component) as unknown) as FunctionComponent; + + return (props: TProps) => { + const [cachedProps, setCachedProps] = useState(props); + const delayRender = useMemo(() => debounce(setCachedProps, delay), []); + + delayRender(props); + + return React.createElement(MemoizedComponent, cachedProps); + }; +} diff --git a/x-pack/legacy/plugins/lens/public/debounced_component/index.ts b/x-pack/legacy/plugins/lens/public/debounced_component/index.ts new file mode 100644 index 0000000000000..ed940fed56112 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/debounced_component/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './debounced_component'; diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx b/x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx new file mode 100644 index 0000000000000..c296dd9ab063a --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { mount } from 'enzyme'; +import { RootDragDropProvider, DragContext } from './providers'; + +jest.useFakeTimers(); + +describe('RootDragDropProvider', () => { + test('reuses contexts for each render', () => { + const contexts: any[] = []; + const TestComponent = ({ name }: { name: string }) => { + const context = useContext(DragContext); + contexts.push(context); + return ( +
+ {name} {!!context.dragging} +
+ ); + }; + + const RootComponent = ({ name }: { name: string }) => ( + + + + ); + + const component = mount(); + + component.setProps({ name: 'bbbb' }); + + expect(component.find('[data-test-subj="test-component"]').text()).toContain('bbb'); + expect(contexts.length).toEqual(2); + expect(contexts[0]).toStrictEqual(contexts[1]); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx b/x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx index c0b4eb563b32b..3e2b7312274c9 100644 --- a/x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx +++ b/x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; /** * The shape of the drag / drop context. @@ -64,7 +64,7 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } const [state, setState] = useState<{ dragging: unknown }>({ dragging: undefined, }); - const setDragging = (dragging: unknown) => setState({ dragging }); + const setDragging = useMemo(() => (dragging: unknown) => setState({ dragging }), [setState]); return ( @@ -81,5 +81,6 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } * @param props */ export function ChildDragDropProvider({ dragging, setDragging, children }: ProviderProps) { - return {children}; + const value = useMemo(() => ({ dragging, setDragging }), [setDragging, dragging]); + return {children}; } diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx index 677b37beab190..aa3513455dece 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useContext } from 'react'; +import React, { useMemo, useContext, memo } from 'react'; import { EuiSelect } from '@elastic/eui'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; @@ -43,7 +43,7 @@ function getSuggestedVisualizationState( return visualization.initialize(datasource, suggestions[0].state); } -export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { +export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const context = useContext(DragContext); const setVisualizationState = useMemo( () => (newState: unknown) => { @@ -89,4 +89,4 @@ export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { )} ); -} +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx index a10f53a4895a9..8a560e862d607 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, memo, useContext } from 'react'; -import { EuiSelect } from '@elastic/eui'; +import React, { useMemo, memo, useContext, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; import { DatasourceDataPanelProps, Datasource } from '../../../public'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; @@ -36,21 +37,58 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { setState: setDatasourceState, }; + const [showDatasourceSwitcher, setDatasourceSwitcher] = useState(false); + return ( <> - ({ - value: datasourceId, - text: datasourceId, - }))} - value={props.activeDatasource || undefined} - onChange={e => { - props.dispatch({ type: 'SWITCH_DATASOURCE', newDatasourceId: e.target.value }); - }} - /> + {Object.keys(props.datasourceMap).length > 1 && ( + setDatasourceSwitcher(true)} + iconType="gear" + /> + } + isOpen={showDatasourceSwitcher} + closePopover={() => setDatasourceSwitcher(false)} + panelPaddingSize="none" + anchorPosition="rightUp" + > + ( + { + setDatasourceSwitcher(false); + props.dispatch({ + type: 'SWITCH_DATASOURCE', + newDatasourceId: datasourceId, + }); + }} + > + {datasourceId} + + ))} + /> + + )} {props.activeDatasource && !props.datasourceIsLoading && ( diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index d3f7666d0ac5d..73ed3330c3393 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -338,14 +338,16 @@ Object { await waitForPromises(); - const updatedState = {}; + const updatedState = { + title: 'shazm', + }; const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] .setState; act(() => { setDatasourceState(updatedState); }); - expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(3); + expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(2); expect(mockDatasource.renderDataPanel).toHaveBeenLastCalledWith( expect.any(Element), expect.objectContaining({ @@ -501,6 +503,10 @@ Object { instance.update(); }); + afterEach(() => { + instance.unmount(); + }); + it('should have initialized only the initial datasource and visualization', () => { expect(mockDatasource.initialize).toHaveBeenCalled(); expect(mockDatasource2.initialize).not.toHaveBeenCalled(); @@ -511,9 +517,12 @@ Object { it('should initialize other datasource on switch', async () => { act(() => { - instance - .find('select[data-test-subj="datasource-switch"]') - .simulate('change', { target: { value: 'testDatasource2' } }); + instance.find('button[data-test-subj="datasource-switch"]').simulate('click'); + }); + act(() => { + (document.querySelector( + '[data-test-subj="datasource-switch-testDatasource2"]' + ) as HTMLButtonElement).click(); }); expect(mockDatasource2.initialize).toHaveBeenCalled(); }); @@ -522,9 +531,11 @@ Object { const initialState = {}; mockDatasource2.initialize.mockResolvedValue(initialState); - instance - .find('select[data-test-subj="datasource-switch"]') - .simulate('change', { target: { value: 'testDatasource2' } }); + instance.find('button[data-test-subj="datasource-switch"]').simulate('click'); + + (document.querySelector( + '[data-test-subj="datasource-switch-testDatasource2"]' + ) as HTMLButtonElement).click(); await waitForPromises(); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss index b57fe73adb8b2..16590b36db86c 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss @@ -17,22 +17,34 @@ .lnsPageMainContent { display: flex; + overflow: auto; } .lnsSidebar { - @include euiScrollBar; - overflow: hidden auto; - padding: $euiSize; margin: 0; flex: 1 0 18%; - min-width: ($euiSize * 16); - height: 100%; + min-width: ($euiSize * 22); display: flex; flex-direction: column; + position: relative; } .lnsSidebar--right { - min-width: ($euiSize * 18); + min-width: ($euiSize * 22); + @include euiScrollBar; + overflow: hidden auto; + padding: $euiSize; +} + +.lnsSidebarContainer { + flex: 1 0 100%; + overflow: hidden; +} + +.lnsDatasourceSwitch { + position: absolute; + right: $euiSize + $euiSizeXS; + top: $euiSize + $euiSizeXS; } .lnsPageBody { diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx index 90af8106c4459..e0ae92e6bb5c3 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx @@ -14,6 +14,7 @@ import { Datasource, Visualization } from '../../types'; import { getSuggestions, toSwitchAction, Suggestion } from './suggestion_helpers'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/data/public'; import { prependDatasourceExpression } from './expression_helpers'; +import { debouncedComponent } from '../../debounced_component'; export interface SuggestionPanelProps { activeDatasource: Datasource; @@ -86,7 +87,9 @@ const SuggestionPreview = ({ ); }; -export function SuggestionPanel({ +export const SuggestionPanel = debouncedComponent(InnerSuggestionPanel, 2000); + +function InnerSuggestionPanel({ activeDatasource, datasourceState, activeVisualizationId, diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx index dadce6d856bd3..5dc613baf0176 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx @@ -14,7 +14,7 @@ import { createExpressionRendererMock, DatasourceMock, } from '../mocks'; -import { WorkspacePanel, WorkspacePanelProps } from './workspace_panel'; +import { InnerWorkspacePanel, WorkspacePanelProps } from './workspace_panel'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { DragDrop } from '../../drag_drop'; @@ -43,7 +43,7 @@ describe('workspace_panel', () => { it('should render an explanatory text if no visualization is active', () => { instance = mount( - { it('should render an explanatory text if the visualization does not produce an expression', () => { instance = mount( - 'datasource' }} datasourceState={{}} activeVisualizationId="vis" @@ -83,7 +83,7 @@ describe('workspace_panel', () => { it('should render an explanatory text if the datasource does not produce an expression', () => { instance = mount( - null }} datasourceState={{}} activeVisualizationId="vis" @@ -103,7 +103,7 @@ describe('workspace_panel', () => { it('should render the resulting expression using the expression renderer', () => { instance = mount( - 'datasource', @@ -142,7 +142,7 @@ Object { describe('expression failures', () => { it('should show an error message if the expression fails to parse', () => { instance = mount( - 'datasource ||', @@ -170,7 +170,7 @@ Object { }); instance = mount( - 'datasource', @@ -203,7 +203,7 @@ Object { }); instance = mount( - 'datasource', @@ -239,7 +239,7 @@ Object { }); instance = mount( - 'datasource', @@ -282,7 +282,7 @@ Object { beforeEach(() => { mockDispatch = jest.fn(); instance = mount( - - Index Pattern Data Source -
- -
- - timestamp - - - bytes - - - source - -
-
-
-`; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx new file mode 100644 index 0000000000000..a77305a5c3178 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -0,0 +1,270 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallow } from 'enzyme'; +import React, { ChangeEvent, ReactElement } from 'react'; +import { EuiComboBox, EuiFieldSearch, EuiContextMenuPanel } from '@elastic/eui'; +import { IndexPatternPrivateState } from './indexpattern'; +import { DatasourceDataPanelProps } from '../types'; +import { createMockedDragDropContext } from './mocks'; +import { IndexPatternDataPanel } from './datapanel'; +import { FieldItem } from './field_item'; +import { act } from 'react-dom/test-utils'; + +jest.mock('./loader'); + +const initialState: IndexPatternPrivateState = { + currentIndexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + operationId: 'op1', + label: 'My Op', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderDirection: 'asc', + orderBy: { + type: 'alphabetical', + }, + }, + }, + }, + indexPatterns: { + '1': { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'unsupported', + type: 'geo', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + '2': { + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + histogram: { + agg: 'histogram', + interval: 1000, + }, + avg: { + agg: 'avg', + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }, + ], + }, + }, +}; +describe('IndexPattern Data Panel', () => { + let defaultProps: DatasourceDataPanelProps; + + beforeEach(() => { + defaultProps = { + state: initialState, + setState: jest.fn(), + dragDropContext: createMockedDragDropContext(), + }; + }); + + it('should render a warning if there are no index patterns', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="indexPattern-no-indexpatterns"]')).toHaveLength(1); + }); + + it('should call setState when the index pattern is switched', async () => { + const wrapper = shallow(); + + wrapper.find('[data-test-subj="indexPattern-switch-link"]').simulate('click'); + + const comboBox = wrapper.find(EuiComboBox); + + comboBox.prop('onChange')!([ + { + label: initialState.indexPatterns['2'].title, + value: '2', + }, + ]); + + expect(defaultProps.setState).toHaveBeenCalledWith({ + ...initialState, + currentIndexPatternId: '2', + }); + }); + + it('should list all supported fields in the pattern sorted alphabetically', async () => { + const wrapper = shallow(); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'bytes', + 'memory', + 'source', + 'timestamp', + ]); + }); + + it('should filter down by name', async () => { + const wrapper = shallow(); + + act(() => { + wrapper.find(EuiFieldSearch).prop('onChange')!({ target: { value: 'mem' } } as ChangeEvent< + HTMLInputElement + >); + }); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'memory', + ]); + }); + + it('should filter down by type', async () => { + const wrapper = shallow(); + + act(() => { + (wrapper + .find(EuiContextMenuPanel) + .prop('items')! + .find( + item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' + )! as ReactElement).props.onClick(); + }); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'bytes', + 'memory', + ]); + }); + + it('should toggle type if clicked again', async () => { + const wrapper = shallow(); + + act(() => { + (wrapper + .find(EuiContextMenuPanel) + .prop('items')! + .find( + item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' + )! as ReactElement).props.onClick(); + }); + + act(() => { + (wrapper + .find(EuiContextMenuPanel) + .prop('items')! + .find( + item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' + )! as ReactElement).props.onClick(); + }); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'bytes', + 'memory', + 'source', + 'timestamp', + ]); + }); + + it('should filter down by type and by name', async () => { + const wrapper = shallow(); + + act(() => { + wrapper.find(EuiFieldSearch).prop('onChange')!({ target: { value: 'mem' } } as ChangeEvent< + HTMLInputElement + >); + }); + + act(() => { + (wrapper + .find(EuiContextMenuPanel) + .prop('items')! + .find( + item => (item as ReactElement).props['data-test-subj'] === 'typeFilter-number' + )! as ReactElement).props.onClick(); + }); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'memory', + ]); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx new file mode 100644 index 0000000000000..4491050150f50 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -0,0 +1,291 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useState, useEffect } from 'react'; +import { + EuiComboBox, + EuiFieldSearch, + // @ts-ignore + EuiHighlight, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiButtonEmpty, + EuiFilterGroup, + EuiFilterButton, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiContextMenuPanelProps, + EuiPopover, + EuiCallOut, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DatasourceDataPanelProps, DataType } from '../types'; +import { IndexPatternPrivateState, IndexPatternField } from './indexpattern'; +import { ChildDragDropProvider } from '../drag_drop'; +import { FieldItem } from './field_item'; +import { FieldIcon } from './field_icon'; + +// TODO the typings for EuiContextMenuPanel are incorrect - watchedItemProps is missing. This can be removed when the types are adjusted +const FixedEuiContextMenuPanel = (EuiContextMenuPanel as any) as React.FunctionComponent< + EuiContextMenuPanelProps & { watchedItemProps: string[] } +>; + +function sortFields(fieldA: IndexPatternField, fieldB: IndexPatternField) { + return fieldA.name.localeCompare(fieldB.name, undefined, { sensitivity: 'base' }); +} + +const supportedFieldTypes = ['string', 'number', 'boolean', 'date']; +const PAGINATION_SIZE = 50; + +const fieldTypeNames: Record = { + string: i18n.translate('xpack.lens.datatypes.string', { defaultMessage: 'string' }), + number: i18n.translate('xpack.lens.datatypes.number', { defaultMessage: 'number' }), + boolean: i18n.translate('xpack.lens.datatypes.boolean', { defaultMessage: 'boolean' }), + date: i18n.translate('xpack.lens.datatypes.date', { defaultMessage: 'date' }), +}; + +export function IndexPatternDataPanel(props: DatasourceDataPanelProps) { + const [state, setState] = useState({ + nameFilter: '', + typeFilter: [] as DataType[], + showIndexPatternSwitcher: false, + isTypeFilterOpen: false, + }); + const [pageSize, setPageSize] = useState(PAGINATION_SIZE); + const [scrollContainer, setScrollContainer] = useState(undefined); + const lazyScroll = () => { + if (scrollContainer) { + const nearBottom = + scrollContainer.scrollTop + scrollContainer.clientHeight > + scrollContainer.scrollHeight * 0.9; + if (nearBottom) { + setPageSize(Math.min(pageSize * 1.5, allFields.length)); + } + } + }; + + useEffect(() => { + if (scrollContainer) { + scrollContainer.scrollTop = 0; + setPageSize(PAGINATION_SIZE); + lazyScroll(); + } + }, [state.nameFilter, state.typeFilter, props.state.currentIndexPatternId]); + + if (Object.keys(props.state.indexPatterns).length === 0) { + return ( + + + +

+ +

+
+
+
+ ); + } + + const allFields = props.state.indexPatterns[props.state.currentIndexPatternId].fields; + const filteredFields = allFields + .filter( + (field: IndexPatternField) => + field.name.toLowerCase().includes(state.nameFilter.toLowerCase()) && + supportedFieldTypes.includes(field.type) + ) + .slice(0, pageSize); + + const availableFieldTypes = _.uniq(filteredFields.map(({ type }) => type)); + const availableFilteredTypes = state.typeFilter.filter(type => + availableFieldTypes.includes(type) + ); + + return ( + + + +
+ {!state.showIndexPatternSwitcher ? ( + <> + +

+ {props.state.indexPatterns[props.state.currentIndexPatternId].title}{' '} +

+
+ setState({ ...state, showIndexPatternSwitcher: true })} + size="xs" + > + ( + + ) + + + ) : ( + ({ + label: title, + value: id, + }))} + inputRef={el => { + if (el) { + el.focus(); + } + }} + selectedOptions={ + props.state.currentIndexPatternId + ? [ + { + label: props.state.indexPatterns[props.state.currentIndexPatternId].title, + value: props.state.indexPatterns[props.state.currentIndexPatternId].id, + }, + ] + : undefined + } + singleSelection={{ asPlainText: true }} + isClearable={false} + onBlur={() => { + setState({ ...state, showIndexPatternSwitcher: false }); + }} + onChange={choices => { + props.setState({ + ...props.state, + currentIndexPatternId: choices[0].value as string, + }); + + setState({ + ...state, + showIndexPatternSwitcher: false, + nameFilter: '', + typeFilter: [], + }); + }} + /> + )} +
+
+ + + + { + setState({ ...state, nameFilter: e.target.value }); + }} + aria-label={i18n.translate('xpack.lens.indexPatterns.filterByNameAriaLabel', { + defaultMessage: 'Search fields', + })} + /> + + + + setState({ ...state, isTypeFilterOpen: false })} + button={ + + setState({ ...state, isTypeFilterOpen: !state.isTypeFilterOpen }) + } + iconType="arrowDown" + data-test-subj="indexPatternTypeFilterButton" + isSelected={state.isTypeFilterOpen} + numFilters={availableFieldTypes.length} + hasActiveFilters={availableFilteredTypes.length > 0} + numActiveFilters={availableFilteredTypes.length} + > + {i18n.translate('xpack.lens.indexPatterns.typeFilterLabel', { + defaultMessage: 'Types', + })} + + } + > + ( + + setState({ + ...state, + typeFilter: state.typeFilter.includes(type) + ? state.typeFilter.filter(t => t !== type) + : [...state.typeFilter, type], + }) + } + > + {fieldTypeNames[type]} + + ))} + /> + + + + +
{ + if (el && !el.dataset.dynamicScroll) { + el.dataset.dynamicScroll = 'true'; + setScrollContainer(el); + } + }} + onScroll={lazyScroll} + > +
+ {filteredFields + .filter( + field => + state.typeFilter.length === 0 || + state.typeFilter.includes(field.type as DataType) + ) + .sort(sortFields) + .map(field => ( + + ))} +
+
+
+
+
+ ); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx index 0f0ba36fbf8ee..2af755568c2a7 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx @@ -135,7 +135,10 @@ describe('IndexPatternDimensionPanel', () => { it('should pass the right arguments to getPotentialColumns', async () => { wrapper = shallow(); - expect(getPotentialColumns as jest.Mock).toHaveBeenCalledWith(state, 1); + expect(getPotentialColumns as jest.Mock).toHaveBeenCalledWith( + state.indexPatterns[state.currentIndexPatternId].fields, + 1 + ); }); it('should call the filterOperations function', () => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx index 719d4fc5a89af..e351acb07dc7b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx @@ -5,7 +5,7 @@ */ import _ from 'lodash'; -import React from 'react'; +import React, { memo, useMemo } from 'react'; import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon } from '@elastic/eui'; import { Storage } from 'ui/storage'; import { i18n } from '@kbn/i18n'; @@ -32,8 +32,17 @@ export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { storage: Storage; }; -export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProps) { - const columns = getPotentialColumns(props.state, props.suggestedPriority); +export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPanel( + props: IndexPatternDimensionPanelProps +) { + const columns = useMemo( + () => + getPotentialColumns( + props.state.indexPatterns[props.state.currentIndexPatternId].fields, + props.suggestedPriority + ), + [props.state.indexPatterns[props.state.currentIndexPatternId].fields, props.suggestedPriority] + ); const filteredColumns = columns.filter(col => { return props.filterOperations(columnToOperation(col)); @@ -99,4 +108,4 @@ export function IndexPatternDimensionPanel(props: IndexPatternDimensionPanelProp ); -} +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx index 6fb1b8eb9f21a..2f7388e5bbc70 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx @@ -7,14 +7,20 @@ import _ from 'lodash'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiComboBox } from '@elastic/eui'; +import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiComboBoxOptionProps } from '@elastic/eui'; import classNames from 'classnames'; +import { + // @ts-ignore + EuiHighlight, +} from '@elastic/eui'; import { IndexPatternColumn, FieldBasedIndexPatternColumn, OperationType, BaseIndexPatternColumn, } from '../indexpattern'; +import { FieldIcon } from '../field_icon'; +import { DataType } from '../../types'; import { hasField, sortByField } from '../utils'; export interface FieldSelectProps { @@ -81,7 +87,7 @@ export function FieldSelect({ options: uniqueColumnsByField .map(col => ({ label: col.sourceField, - value: col.operationId, + value: { operationId: col.operationId, dataType: col.dataType }, compatible: isCompatibleWithCurrentOperation(col), })) .sort(({ compatible: a }, { compatible: b }) => { @@ -108,7 +114,7 @@ export function FieldSelect({ placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', { defaultMessage: 'Field', })} - options={fieldOptions} + options={(fieldOptions as unknown) as EuiComboBoxOptionProps[]} isInvalid={Boolean(incompatibleSelectedOperationType && selectedColumn)} selectedOptions={ selectedColumn @@ -130,11 +136,24 @@ export function FieldSelect({ } const column: IndexPatternColumn = filteredColumns.find( - ({ operationId }) => operationId === choices[0].value + ({ operationId }) => + operationId === ((choices[0].value as unknown) as { operationId: string }).operationId )!; onChangeColumn(column); }} + renderOption={(option, searchValue) => { + return ( + + + + + + {option.label} + + + ); + }} /> ); } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx new file mode 100644 index 0000000000000..741fbc5f43ad6 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { FieldIcon } from './field_icon'; + +describe('FieldIcon', () => { + it('should render numeric icons', () => { + expect(shallow()).toMatchInlineSnapshot(` + + `); + expect(shallow()).toMatchInlineSnapshot(` + + `); + expect(shallow()).toMatchInlineSnapshot(` + + `); + expect(shallow()).toMatchInlineSnapshot(` + + `); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx new file mode 100644 index 0000000000000..d1c2323200038 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ICON_TYPES, palettes, EuiIcon } from '@elastic/eui'; +import classNames from 'classnames'; +import { DataType } from '../types'; + +function stringToNum(s: string) { + return Array.from(s).reduce((acc, ch) => acc + ch.charCodeAt(0), 1); +} + +export type UnwrapArray = T extends Array ? P : T; + +export function FieldIcon({ type }: { type: DataType }) { + const icons: Partial>> = { + boolean: 'invert', + date: 'calendar', + }; + + const iconType = icons[type] || ICON_TYPES.find(t => t === type) || 'empty'; + const { colors } = palettes.euiPaletteColorBlind; + const colorIndex = stringToNum(iconType) % colors.length; + + const classes = classNames( + 'lnsFieldListPanel__fieldIcon', + `lnsFieldListPanel__fieldIcon--${type}` + ); + + return ; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx new file mode 100644 index 0000000000000..620f66dcb1354 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { IndexPatternField } from './indexpattern'; +import { DragDrop } from '../drag_drop'; +import { FieldIcon } from './field_icon'; +import { DataType } from '..'; + +export interface FieldItemProps { + field: IndexPatternField; + highlight?: string; +} + +function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : undefined; +} + +export function FieldItem({ field, highlight }: FieldItemProps) { + const wrappableName = wrapOnDot(field.name)!; + const wrappableHighlight = wrapOnDot(highlight); + const highlightIndex = wrappableHighlight + ? wrappableName.toLowerCase().indexOf(wrappableHighlight.toLowerCase()) + : -1; + const wrappableHighlightableFieldName = + highlightIndex < 0 ? ( + wrappableName + ) : ( + + {wrappableName.substr(0, highlightIndex)} + {wrappableName.substr(highlightIndex, wrappableHighlight!.length)} + {wrappableName.substr(highlightIndex + wrappableHighlight!.length)} + + ); + + return ( + + + + {wrappableHighlightableFieldName} + + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss index 877afd3fcbbc4..021a4e73ecd53 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss @@ -1,5 +1,70 @@ @import './dimension_panel/index'; -.lnsIndexPattern__dimensionPopover { - max-width: 600px; +.lnsIndexPatternDataPanel__header { + display: flex; + align-items: center; + height: $euiSize * 3; + margin-top: -$euiSizeS; + + + & > .lnsIndexPatternDataPanel__changeLink { + flex: 0 0 auto; + margin: 0 $euiSize; + } +} + +.lnsIndexPatternDataPanel__filter-wrapper { + flex-grow: 0; +} + +.lnsIndexPatternDataPanel__header-text { + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; +} + +.lnsIndexPatternDataPanel { + width: 100%; + height: 100%; + padding: $euiSize $euiSize 0; +} + +.lnsFieldListPanel__list-wrapper { + @include euiOverflowShadow; + margin-top: 2px; // form control shadow + position: relative; + flex-grow: 1; + overflow: auto; +} + +.lnsFieldListPanel__list { + padding-top: $euiSizeS; + scrollbar-width: thin; + position: absolute; + top: 0; + left: 0; + right: 0; + @include euiScrollBar; +} + +.lnsFieldListPanel__field { + @include euiFontSizeS; + background: $euiColorEmptyShade; + border-radius: $euiBorderRadius; + padding: $euiSizeS; + display: flex; + align-items: center; + margin-bottom: $euiSizeXS; + font-weight: $euiFontWeightMedium; + transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance; + + &:hover { + @include euiBottomShadowMedium; + z-index: 2; + cursor: grab; + } +} + +.lnsFieldListPanel__fieldName { + margin-left: $euiSizeXS; } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index b8d19fc1987bb..c605260b0c04b 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -4,9 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; -import React from 'react'; -import { EuiComboBox } from '@elastic/eui'; import chromeMock from 'ui/chrome'; import { data as dataMock } from '../../../../../../src/legacy/core_plugins/data/public/setup'; import { localStorage as storageMock } from 'ui/storage/storage_service'; @@ -16,11 +13,9 @@ import { getIndexPatternDatasource, IndexPatternPersistedState, IndexPatternPrivateState, - IndexPatternDataPanel, IndexPatternColumn, } from './indexpattern'; import { DatasourcePublicAPI, Operation, Datasource } from '../types'; -import { createMockedDragDropContext } from './mocks'; jest.mock('./loader'); // chrome, notify, storage are used by ./plugin @@ -172,51 +167,6 @@ describe('IndexPattern Data Source', () => { }); }); - describe('#renderDataPanel', () => { - let state: IndexPatternPrivateState; - - beforeEach(async () => { - state = await indexPatternDatasource.initialize(persistedState); - }); - - it('should match snapshot', () => { - expect( - shallow( - {}} - /> - ) - ).toMatchSnapshot(); - }); - - it('should call setState when the index pattern is switched', async () => { - const setState = jest.fn(); - - const wrapper = shallow( - - ); - - const comboBox = wrapper.find(EuiComboBox); - - comboBox.prop('onChange')!([ - { - label: expectedIndexPatterns['2'].title, - value: '2', - }, - ]); - - expect(setState).toHaveBeenCalledWith({ - ...state, - currentIndexPatternId: '2', - }); - }); - }); - describe('#getPersistedState', () => { it('should persist from saved state', async () => { const state = await indexPatternDatasource.initialize(persistedState); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 01d6ffd6018de..0bbb6f67d5b8a 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -7,7 +7,6 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; -import { EuiComboBox } from '@elastic/eui'; import { I18nProvider } from '@kbn/i18n/react'; import { DatasourceDimensionPanelProps, @@ -18,11 +17,11 @@ import { } from '../types'; import { Query } from '../../../../../../src/legacy/core_plugins/data/public/query'; import { getIndexPatterns } from './loader'; -import { ChildDragDropProvider, DragDrop } from '../drag_drop'; import { toExpression } from './to_expression'; import { IndexPatternDimensionPanel } from './dimension_panel'; import { buildColumnForOperationType, getOperationTypesForField } from './operations'; import { IndexPatternDatasourcePluginPlugins } from './plugin'; +import { IndexPatternDataPanel } from './datapanel'; import { Datasource, DataType } from '..'; export type OperationType = IndexPatternColumn['operationType']; @@ -133,49 +132,6 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & { indexPatterns: Record; }; -export function IndexPatternDataPanel(props: DatasourceDataPanelProps) { - return ( - - Index Pattern Data Source -
- ({ - label: title, - value: id, - }))} - selectedOptions={ - props.state.currentIndexPatternId - ? [ - { - label: props.state.indexPatterns[props.state.currentIndexPatternId].title, - value: props.state.indexPatterns[props.state.currentIndexPatternId].id, - }, - ] - : undefined - } - singleSelection={{ asPlainText: true }} - isClearable={false} - onChange={choices => { - props.setState({ - ...props.state, - currentIndexPatternId: choices[0].value as string, - }); - }} - /> -
- {props.state.currentIndexPatternId && - props.state.indexPatterns[props.state.currentIndexPatternId].fields.map(field => ( - - {field.name} - - ))} -
-
-
- ); -} - export function columnToOperation(column: IndexPatternColumn): Operation { const { dataType, label, isBucketed, operationId } = column; return { @@ -270,7 +226,12 @@ export function getIndexPatternDatasource({ domElement: Element, props: DatasourceDataPanelProps ) { - render(, domElement); + render( + + + , + domElement + ); }, getPublicAPI(state, setState) { @@ -306,8 +267,8 @@ export function getIndexPatternDatasource({ columns: removeProperty(columnId, state.columns), }); }, - moveColumnTo: (columnId: string, targetIndex: number) => {}, - duplicateColumn: (columnId: string) => [], + moveColumnTo: () => {}, + duplicateColumn: () => [], }; }, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts index a835fa76ade8b..7ce2afc831d4a 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.test.ts @@ -168,13 +168,16 @@ describe('getOperationTypesForField', () => { }); it('should include priority', () => { - const columns = getPotentialColumns(state, 1); + const columns = getPotentialColumns( + state.indexPatterns[state.currentIndexPatternId].fields, + 1 + ); expect(columns.every(col => col.suggestedOrder === 1)).toEqual(true); }); it('should list operations by field for a regular index pattern', () => { - const columns = getPotentialColumns(state); + const columns = getPotentialColumns(state.indexPatterns[state.currentIndexPatternId].fields); expect( columns.map(col => [hasField(col) ? col.sourceField : '_documents_', col.operationType]) diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts index 0d9912e60d7b5..700be3c17a159 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations.ts @@ -119,26 +119,24 @@ export function buildColumnForOperationType( } export function getPotentialColumns( - state: IndexPatternPrivateState, + fields: IndexPatternField[], suggestedOrder?: DimensionPriority ): IndexPatternColumn[] { - const fields = state.indexPatterns[state.currentIndexPatternId].fields; - - const columns: IndexPatternColumn[] = fields + const result: IndexPatternColumn[] = fields .map((field, index) => { const validOperations = getOperationTypesForField(field); return validOperations.map(op => - buildColumnForOperationType(index, op, state.columns, suggestedOrder, field) + buildColumnForOperationType(index, op, {}, suggestedOrder, field) ); }) .reduce((prev, current) => prev.concat(current)); operationDefinitions.forEach(operation => { if (operation.isApplicableWithoutField) { - columns.push(operation.buildColumn(operation.type, state.columns, suggestedOrder)); + result.push(operation.buildColumn(operation.type, {}, suggestedOrder)); } }); - return sortByField(columns); + return sortByField(result); }