diff --git a/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/__snapshots__/index.test.js.snap index 6052a4ab34..e10774ac84 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/__snapshots__/index.test.js.snap +++ b/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/__snapshots__/index.test.js.snap @@ -1372,7 +1372,6 @@ exports[` renders the number of operations if there are multiple {Array.isArray(operation) ? ( {}} - options={operation} - value={null} - setValue={this.setOperation} - /> - } + content={} placement="bottom" title="Select Operation to Filter Graph" > diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js index efb6f08925..3030972ccb 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js @@ -14,9 +14,20 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { Icon } from 'antd'; +import FaFilter from 'react-icons/lib/fa/filter.js'; import ExamplesLink from '../ExamplesLink'; -import DetailTable, { _onCell, _makeColumns, _renderCell, _rowKey, _sort } from './DetailTable'; +import DetailTableDropdown from './DetailTableDropdown'; +import DetailTable, { + _makeColumns, + _makeFilterDropdown, + _onCell, + _onFilter, + _renderCell, + _rowKey, + _sort, +} from './DetailTable'; describe('DetailTable', () => { describe('render', () => { @@ -92,13 +103,16 @@ describe('DetailTable', () => { const stringColumn = 'stringCol'; describe('static props', () => { - const makeColumn = def => _makeColumns({ defs: [def] })[0]; + const makeColumn = (def, rows = []) => _makeColumns({ defs: [def], rows })[0]; it('renders string column', () => { expect(makeColumn(stringColumn)).toEqual({ dataIndex: stringColumn, key: stringColumn, title: stringColumn, + filterDropdown: false, + filterIcon: expect.any(Function), + onFilter: expect.any(Function), onCell: expect.any(Function), onHeaderCell: expect.any(Function), render: expect.any(Function), @@ -111,6 +125,9 @@ describe('DetailTable', () => { dataIndex: stringColumn, key: stringColumn, title: stringColumn, + filterDropdown: false, + filterIcon: expect.any(Function), + onFilter: expect.any(Function), onCell: expect.any(Function), onHeaderCell: expect.any(Function), render: expect.any(Function), @@ -129,6 +146,9 @@ describe('DetailTable', () => { dataIndex: stringColumn, key: stringColumn, title: label, + filterDropdown: false, + filterIcon: expect.any(Function), + onFilter: expect.any(Function), onCell: expect.any(Function), onHeaderCell: expect.any(Function), render: expect.any(Function), @@ -149,6 +169,36 @@ describe('DetailTable', () => { ).toBe(styling); }); + it('renders filter icon without filter set', () => { + const icon = makeColumn(stringColumn).filterIcon(); + expect(icon.type).toBe(Icon); + expect(icon.props.type).toBe('filter'); + }); + + it('renders filter icon with filter set', () => { + const icon = makeColumn(stringColumn).filterIcon(true); + expect(icon.type).toBe(FaFilter); + }); + + it('renders filterable column if there are filterable values', () => { + const filterableValues = ['foo', 'bar', { value: 'obj foo' }, { value: 'obj baz' }]; + const expected = new Set(filterableValues.map(v => v.value || v)); + const values = [ + ...filterableValues, + , + , + undefined, + ]; + const rows = values.map(value => ({ + [stringColumn]: value, + })); + const column = makeColumn(stringColumn, rows); + const dropdown = column.filterDropdown(); + + expect(dropdown.props.options).toEqual(expected); + expect(dropdown.type).toBe(DetailTableDropdown); + }); + it('renders object column without sort', () => { expect( makeColumn({ @@ -159,6 +209,9 @@ describe('DetailTable', () => { dataIndex: stringColumn, key: stringColumn, title: stringColumn, + filterDropdown: false, + filterIcon: expect.any(Function), + onFilter: expect.any(Function), onCell: expect.any(Function), onHeaderCell: expect.any(Function), render: expect.any(Function), @@ -175,6 +228,25 @@ describe('DetailTable', () => { })) ); + describe('_makeFilterDropdown', () => { + it('returns DetailsTableDropdown with correct props', () => { + const options = ['foo', 'bar']; + const mockAntdDropdownProps = { + foo: 'bar', + bar: 'baz', + baz: 'foo', + }; + const filterDropdown = _makeFilterDropdown(stringColumn, options)(mockAntdDropdownProps); + + expect(filterDropdown.type).toBe(DetailTableDropdown); + expect(filterDropdown.props).toEqual({ + ...mockAntdDropdownProps, + options, + }); + expect(filterDropdown.key).toBe(stringColumn); + }); + }); + describe('_onCell', () => { const onCell = makeTestFn(_onCell); @@ -204,6 +276,36 @@ describe('DetailTable', () => { }); }); + describe('_onFilter', () => { + const onFilter = (filterValue, testValue) => + _onFilter(stringColumn)(filterValue, { [stringColumn]: testValue }); + const value = 'test-value'; + + it('returns true if string value is filter', () => { + expect(onFilter(value, value)).toBe(true); + }); + + it('returns true if object value is filter', () => { + expect(onFilter(value, { value })).toBe(true); + }); + + it('returns false if string value is not filter', () => { + expect(onFilter(value, `not-${value}`)).toBe(false); + }); + + it('returns false if object value is not filter', () => { + expect(onFilter(value, { value: `not-${value}` })).toBe(false); + }); + + it('returns false for array value', () => { + expect(onFilter(value, [value])).toBe(false); + }); + + it('returns false for undefined value', () => { + expect(onFilter(value)).toBe(false); + }); + }); + describe('_renderCell', () => { it('renders a string', () => { expect(_renderCell('a')).toBe('a'); diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx index a53b74f941..c7687cddca 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx @@ -13,12 +13,21 @@ // limitations under the License. import * as React from 'react'; -import { Table } from 'antd'; +import { Icon, Table } from 'antd'; +import FaFilter from 'react-icons/lib/fa/filter.js'; import _isEmpty from 'lodash/isEmpty'; import ExamplesLink, { TExample } from '../ExamplesLink'; +import DetailTableDropdown from './DetailTableDropdown'; -import { TColumnDef, TColumnDefs, TRow, TStyledValue } from './types'; +import { TColumnDef, TColumnDefs, TFilterDropdownProps, TRow, TStyledValue } from './types'; + +// exported for tests +export const _makeFilterDropdown = (dataIndex: string, options: Set) => ( + props: TFilterDropdownProps +) => { + return ; +}; // exported for tests export const _onCell = (dataIndex: string) => (row: TRow) => { @@ -31,6 +40,15 @@ export const _onCell = (dataIndex: string) => (row: TRow) => { }; }; +// exported for tests +export const _onFilter = (dataIndex: string) => (value: string, row: TRow) => { + const data = row[dataIndex]; + if (typeof data === 'object' && !Array.isArray(data) && typeof data.value === 'string') { + return data.value === value; + } + return data === value; +}; + // exported for tests export const _renderCell = (cellData: undefined | string | TStyledValue) => { if (!cellData || typeof cellData !== 'object') return cellData; @@ -62,7 +80,7 @@ export const _sort = (dataIndex: string) => (a: TRow, b: TRow) => { }; // exported for tests -export const _makeColumns = ({ defs }: { defs: TColumnDefs }) => +export const _makeColumns = ({ defs, rows }: { defs: TColumnDefs; rows: TRow[] }) => defs.map((def: TColumnDef | string) => { let dataIndex: string; let key: string; @@ -80,14 +98,29 @@ export const _makeColumns = ({ defs }: { defs: TColumnDefs }) => if (def.preventSort) sortable = false; } + const options = new Set(); + rows.forEach(row => { + const value = row[dataIndex]; + if (typeof value === 'string' && value) options.add(value); + else if (typeof value === 'object' && !Array.isArray(value) && typeof value.value === 'string') { + options.add(value.value); + } + }); + return { dataIndex, key, title, + filterDropdown: Boolean(options.size) && _makeFilterDropdown(dataIndex, options), + filterIcon: (filtered: boolean) => { + if (filtered) return ; + return ; + }, onCell: _onCell(dataIndex), onHeaderCell: () => ({ style, }), + onFilter: _onFilter(dataIndex), render: _renderCell, sorter: sortable && _sort(dataIndex), }; @@ -138,7 +171,7 @@ export default function DetailTable({ *:not(:first-child) { + margin-left: 0.3em; +} + +.DetailTableDropdown--Btn.Apply { + background: #4c21ce; +} + +.DetailTableDropdown--Btn.Cancel { + background: #007272; +} + +.DetailTableDropdown--Btn.Clear { + background: #ff2626; +} + +.DetailTableDropdown--Tooltip { + max-width: unset; + white-space: nowrap; +} + +.DetailTableDropdown--Tooltip--Body { + display: flex; + flex-direction: column; +} diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.test.js b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.test.js new file mode 100644 index 0000000000..1f4a6a07f0 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.test.js @@ -0,0 +1,121 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { shallow } from 'enzyme'; +import { Button } from 'antd'; + +import FilteredList from '../FilteredList'; +import DetailTableDropdown from './DetailTableDropdown'; + +describe('DetailTable', () => { + const options = ['foo', 'bar', 'baz']; + const props = { + clearFilters: jest.fn(), + confirm: jest.fn(), + options, + selectedKeys: options.slice(1), + setSelectedKeys: jest.fn(), + }; + let wrapper; + + beforeEach(() => { + props.clearFilters.mockReset(); + props.confirm.mockReset(); + props.setSelectedKeys.mockReset(); + wrapper = shallow(); + }); + + describe('render', () => { + it('renders as expected', () => { + expect(wrapper).toMatchSnapshot(); + }); + + it('filters duplicates and numbers out of selectedKeys', () => { + const dupedKeysWithNumbers = props.selectedKeys + .concat(props.selectedKeys) + .concat([4, 8, 15, 16, 23, 42]); + wrapper.setProps({ selectedKeys: dupedKeysWithNumbers }); + expect(wrapper.find(FilteredList).prop('value')).toEqual(new Set(props.selectedKeys)); + }); + + it('handles missing clearFilters prop', () => { + wrapper.setProps({ clearFilters: undefined }); + expect(() => + wrapper + .find(Button) + .first() + .simulate('click') + ).not.toThrow(); + }); + }); + + describe('cancel', () => { + const selectedKeys = [options[0]]; + + it('resets to this.selectedKeys on cancel and calls confirm once props reflect cancellation', () => { + wrapper.instance().selected = selectedKeys; + expect(props.confirm).not.toHaveBeenCalled(); + expect(props.setSelectedKeys).not.toHaveBeenCalled(); + + wrapper + .find(Button) + .at(1) + .simulate('click'); + expect(props.setSelectedKeys).toHaveBeenCalledTimes(1); + expect(props.setSelectedKeys).toHaveBeenCalledWith(selectedKeys); + expect(props.confirm).not.toHaveBeenCalled(); + + wrapper.setProps({ selectedKeys }); + expect(props.setSelectedKeys).toHaveBeenCalledTimes(1); + expect(props.confirm).toHaveBeenCalledTimes(1); + }); + + it('updates this.selectedKeys on open/close', () => { + expect(wrapper.instance().selected).not.toEqual(selectedKeys); + + wrapper.setProps({ selectedKeys: selectedKeys.slice() }); + expect(wrapper.instance().selected).not.toEqual(selectedKeys); + + wrapper.setProps({ selectedKeys: selectedKeys.slice() }); + expect(wrapper.instance().selected).toEqual(selectedKeys); + }); + + it('maintains this.selectedKeys on changed selection', () => { + wrapper.instance().selected = selectedKeys; + wrapper.setProps({ selectedKeys: props.options.slice(0, props.selectedKeys.length) }); + expect(wrapper.instance().selected).toBe(selectedKeys); + }); + }); + + describe('FilteredList interactions', () => { + const getFn = propName => wrapper.find(FilteredList).prop(propName); + + it('adds values', () => { + const newValues = props.options.map(o => `not-${o}`); + getFn('addValues')(newValues); + expect(props.setSelectedKeys).toHaveBeenCalledWith([...props.selectedKeys, ...newValues]); + }); + + it('removes values', () => { + getFn('removeValues')([props.selectedKeys[0]]); + expect(props.setSelectedKeys).toHaveBeenCalledWith(props.selectedKeys.slice(1)); + }); + + it('sets a value', () => { + getFn('setValue')(props.options[0]); + expect(props.setSelectedKeys).toHaveBeenCalledWith([props.options[0]]); + }); + }); +}); diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.tsx b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.tsx new file mode 100644 index 0000000000..8124d577df --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.tsx @@ -0,0 +1,121 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from 'react'; +import { Button, Tooltip } from 'antd'; +import FaCheck from 'react-icons/lib/fa/check.js'; +import FaTrash from 'react-icons/lib/fa/trash.js'; +import TiCancel from 'react-icons/lib/ti/cancel.js'; + +import FilteredList from '../FilteredList'; + +import { TFilterDropdownProps } from './types'; + +import './DetailTableDropdown.css'; + +type TProps = TFilterDropdownProps & { + options: Set; +}; + +export default class DetailTableDropdown extends React.PureComponent { + cancelled = false; + selected: React.Key[] = []; + + componentDidUpdate(prevProps: TProps) { + const { confirm, selectedKeys } = this.props; + + // If the entries in selectedKeys is unchanged, the dropdown has opened or closed. + // Record the selectedKeys at this time for future cancellations. + if (selectedKeys.length === prevProps.selectedKeys.length) { + const prevKeys = new Set(prevProps.selectedKeys); + if (selectedKeys.every(key => prevKeys.has(key))) { + this.selected = selectedKeys; + } + } + + // Unfortunately antd requires setSelectedKeys and confirm to be called in different cycles. + if (this.cancelled) { + this.cancelled = false; + confirm(); + } + } + + cancel = () => { + // Unfortunately antd requires setSelectedKeys and confirm to be called in different cycles. + this.cancelled = true; + this.props.setSelectedKeys(this.selected); + }; + + render() { + const { clearFilters = () => {}, confirm, options, selectedKeys, setSelectedKeys } = this.props; + + const value = new Set(); + selectedKeys.forEach(selected => { + if (typeof selected === 'string') value.add(selected); + }); + + return ( +
+ { + setSelectedKeys([...selectedKeys, ...values]); + }} + multi + options={Array.from(options)} + removeValues={(values: string[]) => { + const remove = new Set(values); + setSelectedKeys(selectedKeys.filter(key => !remove.has(key))); + }} + setValue={(v: string) => { + setSelectedKeys([v]); + }} + value={value} + /> +
+ + + +
+ + + + + Apply changes to this column{"'"}s filter + Same effect as clicking outside the dropdown +
+ } + > + + +
+
+ + ); + } +} diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailTable.test.js.snap b/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailTable.test.js.snap index 112ecb673e..c5c3e5962b 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailTable.test.js.snap +++ b/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailTable.test.js.snap @@ -18,8 +18,11 @@ exports[`DetailTable render does not duplicate columns 1`] = ` Array [ Object { "dataIndex": "col1", + "filterDropdown": [Function], + "filterIcon": [Function], "key": "col1", "onCell": [Function], + "onFilter": [Function], "onHeaderCell": [Function], "render": [Function], "sorter": [Function], @@ -27,8 +30,11 @@ exports[`DetailTable render does not duplicate columns 1`] = ` }, Object { "dataIndex": "col0", + "filterDropdown": [Function], + "filterIcon": [Function], "key": "col0", "onCell": [Function], + "onFilter": [Function], "onHeaderCell": [Function], "render": [Function], "sorter": [Function], @@ -69,8 +75,11 @@ exports[`DetailTable render infers all columns 1`] = ` Array [ Object { "dataIndex": "col0", + "filterDropdown": [Function], + "filterIcon": [Function], "key": "col0", "onCell": [Function], + "onFilter": [Function], "onHeaderCell": [Function], "render": [Function], "sorter": [Function], @@ -78,8 +87,11 @@ exports[`DetailTable render infers all columns 1`] = ` }, Object { "dataIndex": "col1", + "filterDropdown": [Function], + "filterIcon": [Function], "key": "col1", "onCell": [Function], + "onFilter": [Function], "onHeaderCell": [Function], "render": [Function], "sorter": [Function], @@ -116,8 +128,11 @@ exports[`DetailTable render infers missing columns 1`] = ` Array [ Object { "dataIndex": "col0", + "filterDropdown": [Function], + "filterIcon": [Function], "key": "col0", "onCell": [Function], + "onFilter": [Function], "onHeaderCell": [Function], "render": [Function], "sorter": [Function], @@ -125,8 +140,11 @@ exports[`DetailTable render infers missing columns 1`] = ` }, Object { "dataIndex": "col1", + "filterDropdown": [Function], + "filterIcon": [Function], "key": "col1", "onCell": [Function], + "onFilter": [Function], "onHeaderCell": [Function], "render": [Function], "sorter": [Function], @@ -163,8 +181,11 @@ exports[`DetailTable render renders given rows and columns 1`] = ` Array [ Object { "dataIndex": "col1", + "filterDropdown": [Function], + "filterIcon": [Function], "key": "col1", "onCell": [Function], + "onFilter": [Function], "onHeaderCell": [Function], "render": [Function], "sorter": [Function], @@ -172,8 +193,11 @@ exports[`DetailTable render renders given rows and columns 1`] = ` }, Object { "dataIndex": "col0", + "filterDropdown": [Function], + "filterIcon": [Function], "key": "col0", "onCell": [Function], + "onFilter": [Function], "onHeaderCell": [Function], "render": [Function], "sorter": [Function], diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailTableDropdown.test.js.snap b/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailTableDropdown.test.js.snap new file mode 100644 index 0000000000..3b8f436632 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailTableDropdown.test.js.snap @@ -0,0 +1,121 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DetailTable render renders as expected 1`] = ` +
+ +
+ + + +
+ + + + + + Apply changes to this column + ' + s filter + + + Same effect as clicking outside the dropdown + +
+ } + transitionName="zoom-big-fast" + > + + +
+
+ +`; diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/types.tsx b/packages/jaeger-ui/src/components/common/DetailsCard/types.tsx index 1fc6c533c9..77be8c4c14 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/types.tsx +++ b/packages/jaeger-ui/src/components/common/DetailsCard/types.tsx @@ -32,3 +32,10 @@ export type TColumnDefs = (string | TColumnDef)[]; export type TRow = Record; export type TDetails = string | string[] | TRow[]; + +export type TFilterDropdownProps = { + clearFilters?: () => void; + confirm: () => void; + selectedKeys: React.Key[]; + setSelectedKeys: (selectedKeys: React.Key[]) => void; +}; diff --git a/packages/jaeger-ui/src/components/common/FilteredList/ListItem.css b/packages/jaeger-ui/src/components/common/FilteredList/ListItem.css index 07a550f27b..545fa13c39 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/ListItem.css +++ b/packages/jaeger-ui/src/components/common/FilteredList/ListItem.css @@ -60,3 +60,7 @@ limitations under the License. font-weight: 500; padding: 0; } + +.FilteredList--ListItem--Checkbox { + margin-right: 0.5em; +} diff --git a/packages/jaeger-ui/src/components/common/FilteredList/ListItem.test.js b/packages/jaeger-ui/src/components/common/FilteredList/ListItem.test.js index 0d831e7bee..d3225f34d0 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/ListItem.test.js +++ b/packages/jaeger-ui/src/components/common/FilteredList/ListItem.test.js @@ -19,22 +19,21 @@ import ListItem from './ListItem'; describe('', () => { let wrapper; - let props; - let setValue; + const props = { + style: {}, + index: 0, + data: { + setValue: jest.fn(), + focusedIndex: null, + highlightQuery: '', + options: ['a', 'b'], + selectedValue: null, + }, + }; + const selectedValue = props.data.options[props.index]; beforeEach(() => { - setValue = jest.fn(); - props = { - style: {}, - index: 0, - data: { - setValue, - focusedIndex: null, - highlightQuery: '', - options: ['a', 'b'], - selectedValue: null, - }, - }; + props.data.setValue.mockReset(); wrapper = shallow(); }); @@ -49,14 +48,70 @@ describe('', () => { }); it('is selected when options[index] == selectedValue', () => { - const data = { ...props.data, selectedValue: props.data.options[props.index] }; + const data = { ...props.data, selectedValue }; wrapper.setProps({ data }); expect(wrapper).toMatchSnapshot(); }); it('sets the value when clicked', () => { - expect(setValue.mock.calls.length).toBe(0); + expect(props.data.setValue.mock.calls.length).toBe(0); wrapper.simulate('click'); - expect(setValue.mock.calls).toEqual([[props.data.options[props.index]]]); + expect(props.data.setValue.mock.calls).toEqual([[selectedValue]]); + }); + + describe('multi mode', () => { + const addValues = jest.fn(); + const removeValues = jest.fn(); + const data = { ...props.data, multi: true }; + + beforeEach(() => { + wrapper.setProps({ data }); + addValues.mockReset(); + removeValues.mockReset(); + }); + + it('renders without exploding', () => { + expect(wrapper).toMatchSnapshot(); + }); + + it('renders as selected when selected', () => { + wrapper.setProps({ data: { ...data, selectedValue } }); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders as selected when selected with others', () => { + wrapper.setProps({ data: { ...data, selectedValue: new Set(props.data.options) } }); + expect(wrapper).toMatchSnapshot(); + }); + + it('no-ops on click when multi add/remove functions are not both available', () => { + expect(() => wrapper.simulate('click')).not.toThrow(); + + wrapper.setProps({ data: { ...data, addValues } }); + expect(() => wrapper.simulate('click')).not.toThrow(); + expect(addValues).not.toHaveBeenCalled(); + expect(removeValues).not.toHaveBeenCalled(); + + wrapper.setProps({ data: { ...data, removeValues } }); + expect(() => wrapper.simulate('click')).not.toThrow(); + expect(addValues).not.toHaveBeenCalled(); + expect(removeValues).not.toHaveBeenCalled(); + }); + + it('selects value when multi add/remove functions are both available', () => { + wrapper.setProps({ data: { ...data, addValues, removeValues } }); + wrapper.simulate('click'); + expect(addValues).toHaveBeenCalledTimes(1); + expect(addValues).toHaveBeenCalledWith([props.data.options[props.index]]); + expect(removeValues).not.toHaveBeenCalled(); + }); + + it('removes value when multi add/remove functions are both available and value is selected', () => { + wrapper.setProps({ data: { ...data, addValues, removeValues, selectedValue } }); + wrapper.simulate('click'); + expect(removeValues).toHaveBeenCalledTimes(1); + expect(removeValues).toHaveBeenCalledWith([props.data.options[props.index]]); + expect(addValues).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/jaeger-ui/src/components/common/FilteredList/ListItem.tsx b/packages/jaeger-ui/src/components/common/FilteredList/ListItem.tsx index ca2405c467..52fbea8acb 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/ListItem.tsx +++ b/packages/jaeger-ui/src/components/common/FilteredList/ListItem.tsx @@ -13,6 +13,7 @@ // limitations under the License. import * as React from 'react'; +import { Checkbox } from 'antd'; import cx from 'classnames'; import { ListChildComponentProps } from 'react-window'; @@ -22,20 +23,36 @@ import './ListItem.css'; interface IListItemProps extends ListChildComponentProps { data: { + addValues?: (values: string[]) => void; focusedIndex: number | null; highlightQuery: string; + multi?: boolean; options: string[]; - selectedValue: string | null; + removeValues?: (values: string[]) => void; + selectedValue: Set | string | null; setValue: (value: string) => void; }; } export default class ListItem extends React.PureComponent { + isSelected = () => { + const { data, index } = this.props; + const { options, selectedValue } = data; + const isSelected = + typeof selectedValue === 'string' || !selectedValue + ? options[index] === selectedValue + : selectedValue.has(options[index]); + return isSelected; + }; + onClicked = () => { const { data, index } = this.props; - const { options, setValue } = data; + const { addValues, multi, options, removeValues, setValue } = data; const value = options[index]; - setValue(value); + if (multi && addValues && removeValues) { + if (this.isSelected()) removeValues([value]); + else addValues([value]); + } else setValue(value); }; render() { @@ -43,10 +60,11 @@ export default class ListItem extends React.PureComponent { // omit the width from the style so the panel can scroll horizontally // eslint-disable-next-line @typescript-eslint/no-unused-vars const { width: _, ...style } = styleOrig; - const { focusedIndex, highlightQuery, options, selectedValue } = data; + const { focusedIndex, highlightQuery, multi, options } = data; + const isSelected = this.isSelected(); const cls = cx('FilteredList--ListItem', { 'is-focused': index === focusedIndex, - 'is-selected': options[index] === selectedValue, + 'is-selected': isSelected, 'is-striped': index % 2, }); return ( @@ -57,6 +75,7 @@ export default class ListItem extends React.PureComponent { role="switch" aria-checked={index === focusedIndex ? 'true' : 'false'} > + {multi && } {highlightMatches(highlightQuery, options[index])} ); diff --git a/packages/jaeger-ui/src/components/common/FilteredList/__snapshots__/ListItem.test.js.snap b/packages/jaeger-ui/src/components/common/FilteredList/__snapshots__/ListItem.test.js.snap index ac9165f4c0..dbdfe929d8 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/__snapshots__/ListItem.test.js.snap +++ b/packages/jaeger-ui/src/components/common/FilteredList/__snapshots__/ListItem.test.js.snap @@ -24,6 +24,60 @@ exports[` is selected when options[index] == selectedValue 1`] = ` `; +exports[` multi mode renders as selected when selected 1`] = ` +
+ + a +
+`; + +exports[` multi mode renders as selected when selected with others 1`] = ` +
+ + a +
+`; + +exports[` multi mode renders without exploding 1`] = ` +
+ + a +
+`; + exports[` renders without exploding 1`] = `
renders without exploding 1`] = `
- + +
renders without exploding 1`] = ` "1", "2", ], + "removeValues": undefined, "selectedValue": null, "setValue": [Function], } diff --git a/packages/jaeger-ui/src/components/common/FilteredList/index.css b/packages/jaeger-ui/src/components/common/FilteredList/index.css index 26813b3e13..29994ae375 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/index.css +++ b/packages/jaeger-ui/src/components/common/FilteredList/index.css @@ -18,10 +18,25 @@ limitations under the License. background: #fafafa; } +.FilteredList--filterCheckbox { + margin: 0 0.5em; +} + .FilteredList--filterWrapper { align-items: center; + background: white; + box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.3); display: flex; + position: relative; + z-index: 10; +} + +.FilteredList--inputWrapper { + align-items: center; + background: white; box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.3); + display: flex; + flex-grow: 1; position: relative; z-index: 10; } @@ -32,11 +47,15 @@ limitations under the License. position: absolute; } +.FilteredList--filterIcon.isMulti { + margin-left: 2em; +} + .FilteredList--filterInput { border: none; flex: 1; height: auto; - padding: 0.5em 0.3em 0.5em 3em; + padding: 0.5em 0.3em 0.5em 2.5em; } .FilteredList--filterInput:focus { diff --git a/packages/jaeger-ui/src/components/common/FilteredList/index.test.js b/packages/jaeger-ui/src/components/common/FilteredList/index.test.js index 218a4561ea..870c1d5776 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/index.test.js +++ b/packages/jaeger-ui/src/components/common/FilteredList/index.test.js @@ -14,6 +14,7 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { Checkbox } from 'antd'; import { FixedSizeList as VList } from 'react-window'; import { Key as EKey } from 'ts-key-enum'; @@ -124,6 +125,118 @@ describe('', () => { }); }); + describe('multi mode checkbox', () => { + const addValues = jest.fn(); + const removeValues = jest.fn(); + const click = checked => wrapper.find(Checkbox).simulate('change', { target: { checked } }); + const isChecked = () => wrapper.find(Checkbox).prop('checked'); + const isIndeterminate = () => wrapper.find(Checkbox).prop('indeterminate'); + + beforeEach(() => { + wrapper.setProps({ multi: true, addValues, removeValues }); + addValues.mockReset(); + removeValues.mockReset(); + }); + + it('is omitted if multi is false or addValues or removeValues is not provided', () => { + wrapper.setProps({ multi: false }); + expect(wrapper.find(Checkbox).length).toBe(0); + + wrapper.setProps({ multi: true, addValues: undefined }); + expect(wrapper.find(Checkbox).length).toBe(0); + + wrapper.setProps({ addValues, removeValues: undefined }); + expect(wrapper.find(Checkbox).length).toBe(0); + }); + + it('is present in multi mode', () => { + expect(wrapper.find(Checkbox).length).toBe(1); + }); + + it('is unchecked if nothing is selected', () => { + expect(isChecked()).toBe(false); + expect(isIndeterminate()).toBe(false); + }); + + it('is indeterminate if one is selected', () => { + wrapper.setProps({ value: words[0] }); + expect(isChecked()).toBe(false); + expect(isIndeterminate()).toBe(true); + }); + + it('is indeterminate if some are selected', () => { + wrapper.setProps({ value: new Set(words) }); + expect(isChecked()).toBe(false); + expect(isIndeterminate()).toBe(true); + }); + + it('is checked if all are selected', () => { + wrapper.setProps({ value: new Set([...words, ...numbers]) }); + expect(isChecked()).toBe(true); + expect(isIndeterminate()).toBe(false); + }); + + it('is unchecked if nothing filtered is selected', () => { + wrapper.setState({ filterText: numbers[0] }); + wrapper.setProps({ value: new Set(words) }); + expect(isChecked()).toBe(false); + expect(isIndeterminate()).toBe(false); + }); + + it('is unchecked if one filtered value is selected', () => { + wrapper.setState({ filterText: numbers[0] }); + wrapper.setProps({ value: new Set(words) }); + expect(isChecked()).toBe(false); + expect(isIndeterminate()).toBe(false); + }); + + it('is indeterminate if one filtered value is selected', () => { + wrapper.setState({ filterText: words[0][0] }); + wrapper.setProps({ value: words[0] }); + expect(isChecked()).toBe(false); + expect(isIndeterminate()).toBe(true); + }); + + it('is indeterminate if some filtered values are selected', () => { + wrapper.setState({ filterText: words[0][0] }); + wrapper.setProps({ value: new Set(words.slice(1)) }); + expect(isChecked()).toBe(false); + expect(isIndeterminate()).toBe(true); + }); + + it('is checked if all filtered values are selected', () => { + wrapper.setState({ filterText: words[0][0] }); + wrapper.setProps({ value: new Set(words) }); + expect(isChecked()).toBe(true); + expect(isIndeterminate()).toBe(false); + }); + + it('unselects all filtered values when clicked and checked', () => { + wrapper.setState({ filterText: words[0][0] }); + click(false); + expect(removeValues).toHaveBeenCalledTimes(1); + expect(removeValues).toHaveBeenCalledWith(words); + expect(addValues).not.toHaveBeenCalled(); + }); + + it('selects all filtered values when clicked and unchecked', () => { + wrapper.setState({ filterText: words[0][0] }); + click(true); + expect(addValues).toHaveBeenCalledTimes(1); + expect(addValues).toHaveBeenCalledWith(words); + expect(removeValues).not.toHaveBeenCalled(); + }); + + it('selects all unselected filtered values when clicked and unchecked', () => { + wrapper.setState({ filterText: words[0][0] }); + wrapper.setProps({ value: words[0] }); + click(true); + expect(addValues).toHaveBeenCalledTimes(1); + expect(addValues).toHaveBeenCalledWith(words.slice(1)); + expect(removeValues).not.toHaveBeenCalled(); + }); + }); + it('escape triggers cancel', () => { expect(props.cancel.mock.calls.length).toBe(0); keyDown(EKey.Escape); diff --git a/packages/jaeger-ui/src/components/common/FilteredList/index.tsx b/packages/jaeger-ui/src/components/common/FilteredList/index.tsx index 55b3a7507a..9518bad5f3 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/index.tsx +++ b/packages/jaeger-ui/src/components/common/FilteredList/index.tsx @@ -13,6 +13,7 @@ // limitations under the License. import * as React from 'react'; +import { Checkbox, Tooltip } from 'antd'; import _debounce from 'lodash/debounce'; import matchSorter from 'match-sorter'; import IoIosSearch from 'react-icons/lib/io/ios-search'; @@ -23,11 +24,17 @@ import ListItem from './ListItem'; import './index.css'; +const ITEM_HEIGHT = 35; +const MAX_HEIGHT = 375; + type TProps = { - cancel: () => void; + addValues?: (values: string[]) => void; + cancel?: () => void; + multi?: boolean; options: string[]; - value: string | null; + removeValues?: (values: string[]) => void; setValue: (value: string) => void; + value: Set | string | null; }; type TState = { @@ -60,10 +67,48 @@ export default class FilteredList extends React.PureComponent { }; isMouseWithin() { + /* istanbul ignore next */ const { current } = this.wrapperRef; + /* istanbul ignore next */ return current != null && current.matches(':hover'); } + private getFilteredCheckbox(filtered: string[]) { + const { addValues, removeValues, options, value } = this.props; + if (!addValues || !removeValues) return null; + + const valueSet = typeof value === 'string' || !value ? new Set([value]) : value; + let checkedCount = 0; + let indeterminate = false; + for (let i = 0; i < filtered.length; i++) { + const match = valueSet.has(filtered[i]); + if (match) checkedCount++; + if (checkedCount && checkedCount <= i) { + indeterminate = true; + break; + } + } + const checked = Boolean(checkedCount) && checkedCount === filtered.length; + const title = `Click to ${checked ? 'unselect' : 'select'} all ${ + filtered.length < options.length ? 'filtered ' : '' + }options`; + + return ( + + { + if (newCheckedState) addValues(filtered.filter(f => !valueSet.has(f))); + else removeValues(filtered); + }} + indeterminate={indeterminate} + /> + + ); + } + private getFilteredOptions = () => { const { options } = this.props; const { filterText } = this.state; @@ -81,7 +126,7 @@ export default class FilteredList extends React.PureComponent { case EKey.Escape: { const { cancel } = this.props; this.setState({ filterText: '', focusedIndex: null }); - cancel(); + if (cancel) cancel(); break; } case EKey.ArrowUp: @@ -130,37 +175,44 @@ export default class FilteredList extends React.PureComponent { }; render() { - const { value } = this.props; + const { addValues, multi, options, removeValues, value } = this.props; const { filterText, focusedIndex } = this.state; const filteredOptions = this.getFilteredOptions(); + const filteredCheckbox = multi && this.getFilteredCheckbox(filteredOptions); const data = { + addValues, focusedIndex, highlightQuery: filterText, + multi, options: filteredOptions, + removeValues, selectedValue: value, setValue: this.setValue, }; return (
- +
+ {filteredCheckbox} + +