diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx index 4d4b4018d75a7..2fb2bef7f9787 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx @@ -9,7 +9,8 @@ import { shallow } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { EuiPopover, EuiLink } from '@elastic/eui'; import { createMockedIndexPattern } from '../../../mocks'; -import { FilterPopover, QueryInput, LabelInput } from './filter_popover'; +import { FilterPopover, QueryInput } from './filter_popover'; +import { LabelInput } from '../shared_components'; jest.mock('.', () => ({ isQueryValid: () => true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx index cdfa19f53a13a..91adbcecaf897 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx @@ -7,11 +7,12 @@ import './filter_popover.scss'; import React, { MouseEventHandler, useState } from 'react'; import { useDebounce } from 'react-use'; -import { EuiPopover, EuiFieldText, EuiSpacer, keys } from '@elastic/eui'; +import { EuiPopover, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FilterValue, defaultLabel, isQueryValid } from '.'; import { IndexPattern } from '../../../types'; import { QueryStringInput, Query } from '../../../../../../../../src/plugins/data/public'; +import { LabelInput } from '../shared_components'; export const FilterPopover = ({ filter, @@ -51,6 +52,7 @@ export const FilterPopover = ({ return ( setPopoverOpen(false)} + dataTestSubj="indexPattern-filters-label" /> ); @@ -141,53 +144,3 @@ export const QueryInput = ({ /> ); }; - -export const LabelInput = ({ - value, - onChange, - placeholder, - inputRef, - onSubmit, -}: { - value: string; - onChange: (value: string) => void; - placeholder: string; - inputRef: React.MutableRefObject; - onSubmit: () => void; -}) => { - const [inputValue, setInputValue] = useState(value); - - React.useEffect(() => { - setInputValue(value); - }, [value, setInputValue]); - - useDebounce(() => onChange(inputValue), 256, [inputValue]); - - const handleInputChange = (e: React.ChangeEvent) => { - const val = String(e.target.value); - setInputValue(val); - }; - - return ( - { - if (node) { - inputRef.current = node; - } - }} - onKeyDown={({ key }: React.KeyboardEvent) => { - if (keys.ENTER === key) { - onSubmit(); - } - }} - prepend={i18n.translate('xpack.lens.indexPattern.filters.label', { - defaultMessage: 'Label', - })} - /> - ); -}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx index 13854d1ca91d6..6364d3913bf57 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx @@ -231,7 +231,7 @@ describe('filters', () => { expect( instance .find('[data-test-subj="indexPattern-filters-existingFilterContainer"]') - .at(2) + .at(3) .text() ).toEqual('src : 2'); }); @@ -250,7 +250,7 @@ describe('filters', () => { ); instance - .find('[data-test-subj="indexPattern-filters-existingFilterDelete"]') + .find('[data-test-subj="lns-customBucketContainer-remove"]') .at(2) .simulate('click'); expect(setStateSpy).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx index c740f8466e1b1..bca64d1f2f362 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx @@ -8,27 +8,14 @@ import './filters.scss'; import React, { MouseEventHandler, useState } from 'react'; import { omit } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { - EuiDragDropContext, - EuiDraggable, - EuiDroppable, - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - euiDragDropReorder, - EuiButtonIcon, - EuiButtonEmpty, - EuiIcon, - EuiFormRow, - EuiLink, - htmlIdGenerator, -} from '@elastic/eui'; +import { EuiFormRow, EuiLink, htmlIdGenerator } from '@elastic/eui'; import { updateColumnParam } from '../../../state_helpers'; import { OperationDefinition } from '../index'; import { FieldBasedIndexPatternColumn } from '../column_types'; import { FilterPopover } from './filter_popover'; import { IndexPattern } from '../../../types'; import { Query, esKuery, esQuery } from '../../../../../../../../src/plugins/data/public'; +import { NewBucketButton, DragDropBuckets, DraggableBucketContainer } from '../shared_components'; const generateId = htmlIdGenerator(); @@ -37,10 +24,11 @@ export interface Filter { input: Query; label: string; } + export interface FilterValue { + id: string; input: Query; label: string; - id: string; } const customQueryLabel = i18n.translate('xpack.lens.indexPattern.customQuery', { @@ -73,11 +61,6 @@ export const isQueryValid = (input: Query, indexPattern: IndexPattern) => { } }; -interface DraggableLocation { - droppableId: string; - index: number; -} - export interface FiltersIndexPatternColumn extends FieldBasedIndexPatternColumn { operationType: 'filters'; params: { @@ -219,123 +202,67 @@ export const FilterList = ({ ) ); - const onDragEnd = ({ - source, - destination, - }: { - source?: DraggableLocation; - destination?: DraggableLocation; - }) => { - if (source && destination) { - const items = euiDragDropReorder(localFilters, source.index, destination.index); - updateFilters(items); - } - }; - return ( <> - setIsOpenByCreation(false)}> - - {localFilters?.map((filter: FilterValue, idx: number) => { - const { input, label, id } = filter; - const queryIsValid = isQueryValid(input, indexPattern); + setIsOpenByCreation(false)} + droppableId="FILTERS_DROPPABLE_AREA" + items={localFilters} + > + {localFilters?.map((filter: FilterValue, idx: number) => { + const isInvalid = !isQueryValid(filter.input, indexPattern); - return ( - - {(provided) => ( - - - {/* Empty for spacing */} - - - - - ( - - {label || input.query || defaultLabel} - - )} - setFilter={(f: FilterValue) => { - onChangeValue(f.id, f.input, f.label); - }} - /> - - - { - onRemoveFilter(filter.id); - }} - aria-label={i18n.translate( - 'xpack.lens.indexPattern.filters.removeCustomQuery', - { - defaultMessage: 'Remove custom query', - } - )} - title={i18n.translate('xpack.lens.indexPattern.filters.remove', { - defaultMessage: 'Remove', - })} - /> - - - + return ( + onRemoveFilter(filter.id)} + removeTitle={i18n.translate('xpack.lens.indexPattern.filters.removeCustomQuery', { + defaultMessage: 'Remove custom query', + })} + > + { + onChangeValue(f.id, f.input, f.label); + }} + Button={({ onClick }: { onClick: MouseEventHandler }) => ( + + {filter.label || filter.input.query || defaultLabel} + )} - - ); - })} - - - - + + ); + })} + + { onAddFilter(); setIsOpenByCreation(true); }} - > - {i18n.translate('xpack.lens.indexPattern.filters.addCustomQuery', { + label={i18n.translate('xpack.lens.indexPattern.filters.addCustomQuery', { defaultMessage: 'Add a custom query', })} - + /> ); }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.test.tsx new file mode 100644 index 0000000000000..fc0ea92614370 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.test.tsx @@ -0,0 +1,78 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { EuiIcon } from '@elastic/eui'; +import { DragDropBuckets, DraggableBucketContainer } from '../shared_components'; + +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + EuiDragDropContext: 'eui-drag-drop-context', + EuiDroppable: 'eui-droppable', + EuiDraggable: (props: any) => props.children(), // eslint-disable-line @typescript-eslint/no-explicit-any + }; +}); + +describe('buckets shared components', () => { + describe('DragDropBuckets', () => { + it('should call onDragEnd when dragging ended with reordered items', () => { + const items = [
first
,
second
,
third
]; + const defaultProps = { + items, + onDragStart: jest.fn(), + onDragEnd: jest.fn(), + droppableId: 'TEST_ID', + children: items, + }; + const instance = shallow(); + act(() => { + // simulate dragging ending + instance.props().onDragEnd({ source: { index: 0 }, destination: { index: 1 } }); + }); + + expect(defaultProps.onDragEnd).toHaveBeenCalledWith([ +
second
, +
first
, +
third
, + ]); + }); + }); + describe('DraggableBucketContainer', () => { + const defaultProps = { + isInvalid: false, + invalidMessage: 'invalid', + onRemoveClick: jest.fn(), + removeTitle: 'remove', + children:
popover
, + id: '0', + idx: 0, + }; + it('should render valid component', () => { + const instance = mount(); + const popover = instance.find('[data-test-subj="popover"]'); + expect(popover).toHaveLength(1); + }); + it('should render invalid component', () => { + const instance = mount(); + const iconProps = instance.find(EuiIcon).first().props(); + expect(iconProps.color).toEqual('danger'); + expect(iconProps.type).toEqual('alert'); + expect(iconProps.title).toEqual('invalid'); + }); + it('should call onRemoveClick when remove icon is clicked', () => { + const instance = mount(); + const removeIcon = instance + .find('[data-test-subj="lns-customBucketContainer-remove"]') + .first(); + removeIcon.simulate('click'); + expect(defaultProps.onRemoveClick).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx new file mode 100644 index 0000000000000..62b5f64fb26f2 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/buckets.tsx @@ -0,0 +1,134 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiButtonIcon, + EuiIcon, + EuiDragDropContext, + euiDragDropReorder, + EuiDraggable, + EuiDroppable, + EuiButtonEmpty, +} from '@elastic/eui'; + +export const NewBucketButton = ({ label, onClick }: { label: string; onClick: () => void }) => ( + + {label} + +); + +interface BucketContainerProps { + isInvalid?: boolean; + invalidMessage: string; + onRemoveClick: () => void; + removeTitle: string; + children: React.ReactNode; + dataTestSubj?: string; +} + +const BucketContainer = ({ + isInvalid, + invalidMessage, + onRemoveClick, + removeTitle, + children, + dataTestSubj, +}: BucketContainerProps) => { + return ( + + + {/* Empty for spacing */} + + + + {children} + + + + + + ); +}; + +export const DraggableBucketContainer = ({ + id, + idx, + children, + ...bucketContainerProps +}: { + id: string; + idx: number; + children: React.ReactNode; +} & BucketContainerProps) => { + return ( + + {(provided) => {children}} + + ); +}; + +interface DraggableLocation { + droppableId: string; + index: number; +} + +export const DragDropBuckets = ({ + items, + onDragStart, + onDragEnd, + droppableId, + children, +}: { + items: any; // eslint-disable-line @typescript-eslint/no-explicit-any + onDragStart: () => void; + onDragEnd: (items: any) => void; // eslint-disable-line @typescript-eslint/no-explicit-any + droppableId: string; + children: React.ReactElement[]; +}) => { + const handleDragEnd = ({ + source, + destination, + }: { + source?: DraggableLocation; + destination?: DraggableLocation; + }) => { + if (source && destination) { + const newItems = euiDragDropReorder(items, source.index, destination.index); + onDragEnd(newItems); + } + }; + return ( + + + {children} + + + ); +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/index.tsx new file mode 100644 index 0000000000000..a5cac12196959 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/index.tsx @@ -0,0 +1,8 @@ +/* + * 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 './label_input'; +export * from './buckets'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx new file mode 100644 index 0000000000000..882169c0675b0 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx @@ -0,0 +1,62 @@ +/* + * 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, useEffect } from 'react'; +import { useDebounce } from 'react-use'; +import { EuiFieldText, keys } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export const LabelInput = ({ + value, + onChange, + placeholder, + inputRef, + onSubmit, + dataTestSubj, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + inputRef?: React.MutableRefObject; + onSubmit?: () => void; + dataTestSubj?: string; +}) => { + const [inputValue, setInputValue] = useState(value); + + useEffect(() => { + setInputValue(value); + }, [value, setInputValue]); + + useDebounce(() => onChange(inputValue), 256, [inputValue]); + + const handleInputChange = (e: React.ChangeEvent) => { + const val = String(e.target.value); + setInputValue(val); + }; + + return ( + { + if (inputRef && node) { + inputRef.current = node; + } + }} + onKeyDown={({ key }: React.KeyboardEvent) => { + if (keys.ENTER === key && onSubmit) { + onSubmit(); + } + }} + prepend={i18n.translate('xpack.lens.labelInput.label', { + defaultMessage: 'Label', + })} + /> + ); +};