From f2859b6e0f8a918dd7971bd79d31c1bd137f27e2 Mon Sep 17 00:00:00 2001 From: Everett Ross Date: Mon, 11 May 2020 16:48:47 -0400 Subject: [PATCH 01/12] Test existing files + CircularProgressbar Signed-off-by: Everett Ross --- packages/jaeger-ui/src/api/jaeger.js | 6 +- packages/jaeger-ui/src/api/jaeger.test.js | 20 ++++ .../App/__snapshots__/index.test.js.snap | 88 ++++++++++++++ .../src/components/App/index.test.js | 7 +- .../components/SearchTracePage/url.test.js | 63 +++++++++- .../TimelineColumnResizer.test.js | 56 +++++++++ .../common/CircularProgressbar.test.js | 41 +++++++ .../CircularProgressbar.test.js.snap | 79 +++++++++++++ .../__snapshots__/index.test.js.snap | 23 ++++ .../path-agnostic-decorations/index.test.js | 108 ++++++++++++++++++ 10 files changed, 485 insertions(+), 6 deletions(-) create mode 100644 packages/jaeger-ui/src/components/App/__snapshots__/index.test.js.snap create mode 100644 packages/jaeger-ui/src/components/common/CircularProgressbar.test.js create mode 100644 packages/jaeger-ui/src/components/common/__snapshots__/CircularProgressbar.test.js.snap create mode 100644 packages/jaeger-ui/src/model/path-agnostic-decorations/__snapshots__/index.test.js.snap create mode 100644 packages/jaeger-ui/src/model/path-agnostic-decorations/index.test.js diff --git a/packages/jaeger-ui/src/api/jaeger.js b/packages/jaeger-ui/src/api/jaeger.js index c18bae3a96..7db5b041bd 100644 --- a/packages/jaeger-ui/src/api/jaeger.js +++ b/packages/jaeger-ui/src/api/jaeger.js @@ -80,9 +80,6 @@ const JaegerAPI = { archiveTrace(id) { return getJSON(`${this.apiRoot}archive/${id}`, { method: 'POST' }); }, - fetchQualityMetrics(service, lookback) { - return getJSON(`/qualitymetrics-v2`, { query: { service, lookback } }); - }, fetchDecoration(url) { return getJSON(url); }, @@ -92,6 +89,9 @@ const JaegerAPI = { fetchDependencies(endTs = new Date().getTime(), lookback = DEFAULT_DEPENDENCY_LOOKBACK) { return getJSON(`${this.apiRoot}dependencies`, { query: { endTs, lookback } }); }, + fetchQualityMetrics(service, lookback) { + return getJSON(`/qualitymetrics-v2`, { query: { service, lookback } }); + }, fetchServiceOperations(serviceName) { return getJSON(`${this.apiRoot}services/${encodeURIComponent(serviceName)}/operations`); }, diff --git a/packages/jaeger-ui/src/api/jaeger.test.js b/packages/jaeger-ui/src/api/jaeger.test.js index fc7a57b6f4..045bdd9856 100644 --- a/packages/jaeger-ui/src/api/jaeger.test.js +++ b/packages/jaeger-ui/src/api/jaeger.test.js @@ -48,6 +48,14 @@ describe('archiveTrace', () => { }); }); +describe('fetchDecoration', () => { + it('GETs the specified url', () => { + const url = 'foo.bar.baz'; + JaegerAPI.fetchDecoration(url); + expect(fetchMock).toHaveBeenLastCalledWith(url, defaultOptions); + }); +}); + describe('fetchDeepDependencyGraph', () => { it('GETs the specified query', () => { const query = { service: 'serviceName', start: 400, end: 800 }; @@ -81,6 +89,18 @@ describe('fetchDependencies', () => { }); }); +describe('fetchQualityMetrics', () => { + it('GETs the specified service and lookback', () => { + const lookback = '3h'; + const service = 'test-service'; + JaegerAPI.fetchQualityMetrics(service, lookback); + expect(fetchMock).toHaveBeenLastCalledWith( + `/qualitymetrics-v2?${queryString.stringify({ service, lookback })}`, + defaultOptions + ); + }); +}); + describe('fetchServiceServerOps', () => { it('GETs the specified query', () => { const service = 'serviceName'; diff --git a/packages/jaeger-ui/src/components/App/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/App/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..601feb1701 --- /dev/null +++ b/packages/jaeger-ui/src/components/App/__snapshots__/index.test.js.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`JaegerUIApp does not explode 1`] = ` + + + + + + + + + + + + + + + + + + +`; diff --git a/packages/jaeger-ui/src/components/App/index.test.js b/packages/jaeger-ui/src/components/App/index.test.js index bfdea6cb9b..fd5bfae426 100644 --- a/packages/jaeger-ui/src/components/App/index.test.js +++ b/packages/jaeger-ui/src/components/App/index.test.js @@ -17,6 +17,9 @@ import { shallow } from 'enzyme'; import JaegerUIApp from './index'; -it('JaegerUIApp does not explode', () => { - shallow(); +describe('JaegerUIApp', () => { + it('does not explode', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); }); diff --git a/packages/jaeger-ui/src/components/SearchTracePage/url.test.js b/packages/jaeger-ui/src/components/SearchTracePage/url.test.js index af417be216..d1143b16c7 100644 --- a/packages/jaeger-ui/src/components/SearchTracePage/url.test.js +++ b/packages/jaeger-ui/src/components/SearchTracePage/url.test.js @@ -12,7 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { getUrl, getUrlState, isSameQuery } from './url'; +import * as reactRouterDom from 'react-router-dom'; + +import { MAX_LENGTH } from '../DeepDependencies/Graph/DdgNodeContent/constants'; +import { ROUTE_PATH, getUrl, getUrlState, isSameQuery, matches } from './url'; describe('SearchTracePage/url', () => { const span0 = 'span-0'; @@ -22,6 +25,31 @@ describe('SearchTracePage/url', () => { const trace1 = 'trace-1'; const trace2 = 'trace-2'; + describe('matches', () => { + const path = 'path argument'; + let matchPathSpy; + + beforeAll(() => { + matchPathSpy = jest.spyOn(reactRouterDom, 'matchPath'); + }); + + it('calls matchPath with expected arguments', () => { + matches(path); + expect(matchPathSpy).toHaveBeenLastCalledWith(path, { + path: ROUTE_PATH, + strict: true, + exact: true, + }); + }); + + it("returns truthiness of matchPath's return value", () => { + matchPathSpy.mockReturnValueOnce(null); + expect(matches(path)).toBe(false); + matchPathSpy.mockReturnValueOnce({}); + expect(matches(path)).toBe(true); + }); + }); + describe('getUrl', () => { it('handles no args given', () => { expect(getUrl()).toBe('/search'); @@ -96,6 +124,39 @@ describe('SearchTracePage/url', () => { }) ).toBe(`/search?span=${span0}%20${span1}%40${trace0}&span=${span2}%40${trace1}&traceID=${trace2}`); }); + + describe('too long urls', () => { + const oneID = getUrl({ + traceID: trace0, + }); + const lengthBeforeArgs = oneID.indexOf('?'); + const lengthOfOneArg = oneID.length - lengthBeforeArgs; + const maxLengthOfArgs = MAX_LENGTH - lengthBeforeArgs; + + it('limits url length', () => { + const numberOfArgs = Math.ceil(maxLengthOfArgs / lengthOfOneArg); + + expect( + getUrl({ + traceID: new Array(numberOfArgs).fill(trace0), + }).length + ).toBeLessThan(MAX_LENGTH); + }); + + it('does not over shorten', () => { + const numberOfArgs = Math.floor(maxLengthOfArgs / lengthOfOneArg); + const remainder = maxLengthOfArgs % lengthOfOneArg; + const ids = new Array(numberOfArgs).fill(trace0); + ids[ids.length - 1] = `${ids[ids.length - 1]}${'x'.repeat(remainder)}`; + ids.push(trace0); + + expect( + getUrl({ + traceID: ids, + }).length + ).toBe(MAX_LENGTH); + }); + }); }); describe('getUrlState', () => { diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js index 439b387632..693f7a7d8a 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js @@ -65,6 +65,24 @@ describe('', () => { }); }); + it('returns the flipped draggable bounds via _getDraggingBounds()', () => { + const left = 10; + const width = 100; + wrapper.setProps({ rightSide: true }); + instance._rootElm.getBoundingClientRect = () => ({ left, width }); + expect(instance._getDraggingBounds()).toEqual({ + width, + clientXLeft: left, + maxValue: 1 - props.min, + minValue: 1 - props.max, + }); + }); + + it('throws if dragged before rendered', () => { + wrapper.instance()._rootElm = null; + expect(instance._getDraggingBounds).toThrow('invalid state'); + }); + it('handles drag start', () => { const value = Math.random(); expect(wrapper.state('dragPosition')).toBe(null); @@ -72,6 +90,21 @@ describe('', () => { expect(wrapper.state('dragPosition')).toBe(value); }); + it('handles drag update', () => { + const value = props.position * 1.1; + expect(wrapper.state('dragPosition')).toBe(null); + wrapper.instance()._handleDragUpdate({ value }); + expect(wrapper.state('dragPosition')).toBe(value); + }); + + it('handles flipped drag update', () => { + const value = props.position * 1.1; + wrapper.setProps({ rightSide: true }); + expect(wrapper.state('dragPosition')).toBe(null); + wrapper.instance()._handleDragUpdate({ value }); + expect(wrapper.state('dragPosition')).toBe(1 - value); + }); + it('handles drag end', () => { const manager = { resetBounds: jest.fn() }; const value = Math.random(); @@ -81,6 +114,23 @@ describe('', () => { expect(wrapper.state('dragPosition')).toBe(null); expect(props.onChange.mock.calls).toEqual([[value]]); }); + + it('handles flipped drag end', () => { + const manager = { resetBounds: jest.fn() }; + const value = Math.random(); + wrapper.setProps({ rightSide: true }); + wrapper.setState({ dragPosition: 2 * value }); + instance._handleDragEnd({ manager, value }); + expect(manager.resetBounds.mock.calls).toEqual([[]]); + expect(wrapper.state('dragPosition')).toBe(null); + expect(props.onChange.mock.calls).toEqual([[1 - value]]); + }); + + it('cleans up DraggableManager on unmount', () => { + const disposeSpy = jest.spyOn(wrapper.instance()._dragManager, 'dispose'); + wrapper.unmount(); + expect(disposeSpy).toHaveBeenCalledTimes(1); + }); }); it('does not render a dragging indicator when not dragging', () => { @@ -96,4 +146,10 @@ describe('', () => { expect(wrapper.find('.isDraggingLeft').length + wrapper.find('.isDraggingRight').length).toBe(1); expect(wrapper.find('.TimelineColumnResizer--dragger').prop('style').right).toBeDefined(); }); + + it('renders is-flipped classname when positioned on rightSide', () => { + expect(wrapper.find('.is-flipped').length).toBe(0); + wrapper.setProps({ rightSide: true }); + expect(wrapper.find('.is-flipped').length).toBe(1); + }); }); diff --git a/packages/jaeger-ui/src/components/common/CircularProgressbar.test.js b/packages/jaeger-ui/src/components/common/CircularProgressbar.test.js new file mode 100644 index 0000000000..a814826515 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/CircularProgressbar.test.js @@ -0,0 +1,41 @@ +// 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 CircularProgressbar from './CircularProgressbar'; + +describe('CircularProgressbar', () => { + const minProps = { + maxValue: 108, + value: 42, + }; + + const fullProps = { + ...minProps, + backgroundHue: 0, + decorationHue: 120, + strokeWidth: 8, + text: 'test text', + }; + + it('renders as expected with all props', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('handles minimal props', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/packages/jaeger-ui/src/components/common/__snapshots__/CircularProgressbar.test.js.snap b/packages/jaeger-ui/src/components/common/__snapshots__/CircularProgressbar.test.js.snap new file mode 100644 index 0000000000..2cf6efcc3d --- /dev/null +++ b/packages/jaeger-ui/src/components/common/__snapshots__/CircularProgressbar.test.js.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CircularProgressbar handles minimal props 1`] = ` + +`; + +exports[`CircularProgressbar renders as expected with all props 1`] = ` + +`; diff --git a/packages/jaeger-ui/src/model/path-agnostic-decorations/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/model/path-agnostic-decorations/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..bb7d98a6a8 --- /dev/null +++ b/packages/jaeger-ui/src/model/path-agnostic-decorations/__snapshots__/index.test.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extractDecorationFromState prefers operation specific decoration over service decoration 1`] = ` + +`; + +exports[`extractDecorationFromState returns service decoration 1`] = ` + +`; diff --git a/packages/jaeger-ui/src/model/path-agnostic-decorations/index.test.js b/packages/jaeger-ui/src/model/path-agnostic-decorations/index.test.js new file mode 100644 index 0000000000..e737fe607d --- /dev/null +++ b/packages/jaeger-ui/src/model/path-agnostic-decorations/index.test.js @@ -0,0 +1,108 @@ +// 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 _set from 'lodash/set'; +import queryString from 'query-string'; + +import extractDecorationFromState from '.'; + +describe('extractDecorationFromState', () => { + const decorationID = 'test decoration id'; + const service = 'test service'; + const operation = 'test operation'; + const decorationValue = 42; + const decorationMax = 108; + + function makeState({ decoration = decorationID, opValue, opMax, withoutOpValue, withoutOpMax }) { + const state = {}; + const deco = Array.isArray(decoration) ? decoration[0] : decoration; + + _set(state, 'router.location.search', decoration ? queryString.stringify({ decoration }) : ''); + if (opValue !== undefined) + _set(state, `pathAgnosticDecorations.${deco}.withOp.${service}.${operation}`, opValue); + if (opMax !== undefined) _set(state, `pathAgnosticDecorations.${deco}.withOpMax`, opMax); + if (withoutOpValue !== undefined) + _set(state, `pathAgnosticDecorations.${deco}.withoutOp.${service}`, withoutOpValue); + if (withoutOpMax !== undefined) _set(state, `pathAgnosticDecorations.${deco}.withoutOpMax`, withoutOpMax); + + return state; + } + + function extractWrapper(stateArgs, svpOp = { service, operation }) { + return extractDecorationFromState(makeState(stateArgs), svpOp); + } + + it('returns an empty object if url lacks a decorationID', () => { + expect(extractWrapper({ decoration: null })).toEqual({}); + }); + + it('prefers operation specific decoration over service decoration', () => { + const otherValue = 'other value'; + const otherMax = 'other max'; + const res = extractWrapper({ + opValue: decorationValue, + opMax: decorationMax, + withoutOpValue: otherValue, + withoutOpMax: otherMax, + }); + expect(res).toEqual( + expect.objectContaining({ + decorationID, + decorationValue, + }) + ); + expect(res.decorationProgressbar).toMatchSnapshot(); + }); + + it('returns service decoration', () => { + const res = extractWrapper({ + withoutOpValue: decorationValue, + withoutOpMax: decorationMax, + }); + expect(res).toEqual( + expect.objectContaining({ + decorationID, + decorationValue, + }) + ); + expect(res.decorationProgressbar).toMatchSnapshot(); + }); + + it('omits CircularProgressbar if value is a string', () => { + const withoutOpValue = 'without op string value'; + const res = extractWrapper({ + withoutOpValue, + withoutOpMax: decorationMax, + }); + expect(res).toEqual({ + decorationID, + decorationValue: withoutOpValue, + decorationProgressbar: undefined, + }); + }); + + it('uses first decoration if multiple exist in url', () => { + const withoutOpValue = 'without op string value'; + const res = extractWrapper({ + decoration: [decorationID, `not-${decorationID}`], + withoutOpValue, + withoutOpMax: decorationMax, + }); + expect(res).toEqual({ + decorationID, + decorationValue: withoutOpValue, + decorationProgressbar: undefined, + }); + }); +}); From 15718e88a74a13e7835afb9bb6170fee1222373b Mon Sep 17 00:00:00 2001 From: Everett Ross Date: Mon, 11 May 2020 18:39:45 -0400 Subject: [PATCH 02/12] Test QualityMetrics/!index, move & rename Resizer Signed-off-by: Everett Ross --- .../SidePanel/DetailsPanel.test.js | 4 +- .../SidePanel/DetailsPanel.tsx | 4 +- .../__snapshots__/DetailsPanel.test.js.snap | 16 +- .../QualityMetrics/BannerText.test.js | 44 +++ .../QualityMetrics/CountCard.test.js | 36 ++ .../QualityMetrics/ExamplesLink.test.js | 66 ++++ .../components/QualityMetrics/Header.test.js | 96 +++++ .../QualityMetrics/MetricCard.test.js | 104 +++++ .../QualityMetrics/ScoreCard.test.js | 70 ++++ .../__snapshots__/BannerText.test.js.snap | 23 ++ .../__snapshots__/CountCard.test.js.snap | 46 +++ .../__snapshots__/ExamplesLink.test.js.snap | 37 ++ .../__snapshots__/Header.test.js.snap | 75 ++++ .../__snapshots__/MetricCard.test.js.snap | 374 ++++++++++++++++++ .../__snapshots__/ScoreCard.test.js.snap | 98 +++++ .../src/components/QualityMetrics/url.test.js | 97 +++++ .../src/components/QualityMetrics/url.tsx | 2 +- .../TimelineHeaderRow.test.js | 6 +- .../TimelineHeaderRow/TimelineHeaderRow.tsx | 9 +- .../VerticalResizer.css} | 40 +- .../VerticalResizer.test.js} | 20 +- .../VerticalResizer.tsx} | 25 +- 22 files changed, 1225 insertions(+), 67 deletions(-) create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/BannerText.test.js create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/CountCard.test.js create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.test.js create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/Header.test.js create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/MetricCard.test.js create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.test.js create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/BannerText.test.js.snap create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/CountCard.test.js.snap create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/ExamplesLink.test.js.snap create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/Header.test.js.snap create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/MetricCard.test.js.snap create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/ScoreCard.test.js.snap create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/url.test.js rename packages/jaeger-ui/src/components/{TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.css => common/VerticalResizer.css} (55%) rename packages/jaeger-ui/src/components/{TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js => common/VerticalResizer.test.js} (87%) rename packages/jaeger-ui/src/components/{TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx => common/VerticalResizer.tsx} (84%) diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.test.js b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.test.js index a349d4fb59..3b3626e36c 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.test.js +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.test.js @@ -18,7 +18,7 @@ import _set from 'lodash/set'; import stringSupplant from '../../../utils/stringSupplant'; import JaegerAPI from '../../../api/jaeger'; -import ColumnResizer from '../../TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer'; +import VerticalResizer from '../../common/VerticalResizer'; import { UnconnectedDetailsPanel as DetailsPanel } from './DetailsPanel'; describe('', () => { @@ -296,7 +296,7 @@ describe('', () => { const wrapper = shallow(); expect(wrapper.state('width')).not.toBe(width); - wrapper.find(ColumnResizer).prop('onChange')(width); + wrapper.find(VerticalResizer).prop('onChange')(width); expect(wrapper.state('width')).toBe(width); }); }); diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx index 18451fa3b3..9cb96c9c6f 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx @@ -20,8 +20,8 @@ import { connect } from 'react-redux'; import BreakableText from '../../common/BreakableText'; import LoadingIndicator from '../../common/LoadingIndicator'; import NewWindowIcon from '../../common/NewWindowIcon'; +import VerticalResizer from '../../common/VerticalResizer'; import JaegerAPI from '../../../api/jaeger'; -import ColumnResizer from '../../TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer'; import extractDecorationFromState, { TDecorationFromState } from '../../../model/path-agnostic-decorations'; import { TPathAgnosticDecorationSchema, @@ -172,7 +172,7 @@ export class UnconnectedDetailsPanel extends React.PureComponent header="Details" /> )} - + ); } diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/DetailsPanel.test.js.snap b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/DetailsPanel.test.js.snap index 64143fcf26..f14a7e6833 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/DetailsPanel.test.js.snap +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/DetailsPanel.test.js.snap @@ -30,7 +30,7 @@ exports[` render renders 1`] = ` > test decorationValue - render renders detailLink 1`] = ` small={false} /> - render renders details 1`] = ` details="details string" header="Details" /> - render renders details error 1`] = ` details="details error" header="Details" /> - render renders omitted array of operations 1`] = ` > test decorationValue - render renders while loading 1`] = ` small={false} /> - render renders with operation 1`] = ` > test decorationValue - render renders with progressbar 1`] = ` stand-in progressbar - { + it('renders null when props.bannerText is falsy', () => { + expect(shallow().type()).toBe(null); + }); + + it('renders header when props.bannerText is a string', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('renders styled header when props.bannerText is a styled value', () => { + expect( + shallow( + + ) + ).toMatchSnapshot(); + }); +}); diff --git a/packages/jaeger-ui/src/components/QualityMetrics/CountCard.test.js b/packages/jaeger-ui/src/components/QualityMetrics/CountCard.test.js new file mode 100644 index 0000000000..d7e8438e31 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/CountCard.test.js @@ -0,0 +1,36 @@ +// 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 CountCard from './CountCard'; + +describe('CountCard', () => { + const count = 108; + const title = 'Test Title'; + + it('renders null when props.count or props.title is absent', () => { + expect(shallow().type()).toBe(null); + expect(shallow().type()).toBe(null); + }); + + it('renders as expected when given count and title', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('renders as expected when given count, title, and examples', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.test.js b/packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.test.js new file mode 100644 index 0000000000..eeef6a9caf --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.test.js @@ -0,0 +1,66 @@ +// 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 ExamplesLink from './ExamplesLink'; + +describe('ExamplesLink', () => { + const traceLinks = [ + { + traceID: 'foo', + }, + { + traceID: 'bar', + }, + { + traceID: 'baz', + }, + ]; + const spanLinks = traceLinks.map(({ traceID }, i) => ({ + traceID: `${traceID}${i}`, + spanIDs: new Array(i + 1).fill('spanID').map((str, j) => `${str}${i}${j}`), + })); + + it('renders null when props.examples is absent or empty', () => { + expect(shallow().type()).toBe(null); + expect(shallow().type()).toBe(null); + }); + + it('renders as expected when given trace links', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('renders as expected when given span links', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('renders as expected when given both span and trace links', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('renders label text iff props.includeText is true', () => { + expect( + shallow() + .find('a') + .props().children[0] + ).toBe(undefined); + expect( + shallow() + .find('a') + .props().children[0] + ).toBe('Examples '); + }); +}); diff --git a/packages/jaeger-ui/src/components/QualityMetrics/Header.test.js b/packages/jaeger-ui/src/components/QualityMetrics/Header.test.js new file mode 100644 index 0000000000..4fc398ac0f --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/Header.test.js @@ -0,0 +1,96 @@ +// Copyright (c) 2017 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 { InputNumber } from 'antd'; +import debounceMock from 'lodash/debounce'; + +import Header from './Header'; + +jest.mock('lodash/debounce'); + +describe('Header', () => { + const lookback = 4; + const minProps = { + lookback, + setLookback: jest.fn(), + setService: jest.fn(), + }; + const service = 'test service'; + const props = { + ...minProps, + service, + services: ['foo', 'bar', 'baz'], + }; + let wrapper; + let callDebouncedFn; + let setLookbackSpy; + + beforeAll(() => { + debounceMock.mockImplementation(fn => { + setLookbackSpy = jest.fn((...args) => { + callDebouncedFn = () => fn(...args); + }); + return setLookbackSpy; + }); + }); + + beforeEach(() => { + props.setLookback.mockReset(); + setLookbackSpy = undefined; + wrapper = shallow(
); + }); + + describe('rendering', () => { + it('renders as expected with minimum props', () => { + wrapper = shallow(
); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders as expected with full props', () => { + expect(wrapper).toMatchSnapshot(); + }); + + it('renders props.lookback when state.ownInputValue is `undefined`', () => { + expect(wrapper.find(InputNumber).prop('value')).toBe(lookback); + }); + + it('renders state.ownInputValue when it is not `undefined` regardless of props.lookback', () => { + const ownInputValue = 27; + wrapper.setState({ ownInputValue }); + expect(wrapper.find(InputNumber).prop('value')).toBe(ownInputValue); + }); + }); + + describe('setting lookback', () => { + it('no-ops for string values', () => { + wrapper.find(InputNumber).prop('onChange')('foo'); + expect(wrapper.state('ownInputValue')).toBe(undefined); + }); + + it('updates state with numeric value, then clears state and calls props.setLookback after debounce', () => { + const value = 42; + wrapper.find(InputNumber).prop('onChange')(value); + + expect(wrapper.state('ownInputValue')).toBe(value); + expect(setLookbackSpy).toHaveBeenCalledWith(42); + expect(props.setLookback).not.toHaveBeenCalled(); + + callDebouncedFn(); + expect(wrapper.state('ownInputValue')).toBe(undefined); + expect(props.setLookback).toHaveBeenCalledWith(42); + }); + }); +}); diff --git a/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.test.js b/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.test.js new file mode 100644 index 0000000000..5476f51e20 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.test.js @@ -0,0 +1,104 @@ +// 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 MetricCard from './MetricCard'; + +describe('MetricCard', () => { + const metric = { + name: 'Metric Name', + description: 'Metric Description', + metricDocumentationLink: 'metric.documentation.link', + passCount: 108, + passExamples: ['foo'], + failureCount: 255, + failureExamples: ['bar'], + exemptionCount: 42, + exemptionExamples: ['baz'], + }; + const details = [ + { + columns: ['col0', 'col1'], + description: 'Details[0] Description', + }, + { + columns: ['col2', 'col3'], + description: 'Details[1] Description', + rows: [], + }, + { + columns: ['col4', 'col5'], + description: 'Details[2] Description', + rows: [ + { + col4: 'value for fourth column', + col5: 'value for fifth column', + }, + ], + }, + { + columns: ['col6', 'col7'], + description: 'Details[3] Description', + header: 'Details[3] Header', + rows: [ + { + col6: 'value for sixth column', + col7: 'value for seventh column', + }, + ], + }, + ]; + + it('renders as expected without details', () => { + expect(shallow()).toMatchSnapshot(); + expect( + shallow( + + ) + ).toMatchSnapshot(); + }); + + it('renders as expected with details', () => { + expect( + shallow( + + ) + ).toMatchSnapshot(); + }); + + it('renders as expected when passCount is zero', () => { + expect( + shallow( + + ) + ).toMatchSnapshot(); + }); +}); diff --git a/packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.test.js b/packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.test.js new file mode 100644 index 0000000000..dcd63512df --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.test.js @@ -0,0 +1,70 @@ +// 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 ScoreCard from './ScoreCard'; + +describe('ScoreCard', () => { + const link = 'test.link'; + const label = 'Test Score'; + const value = 42; + const max = 108; + + it('renders as expected when score is below max', () => { + expect( + shallow( + + ) + ).toMatchSnapshot(); + }); + + it('renders as expected when score is max', () => { + expect( + shallow( + + ) + ).toMatchSnapshot(); + }); + + it('renders as expected when score is zero', () => { + expect( + shallow( + + ) + ).toMatchSnapshot(); + }); +}); diff --git a/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/BannerText.test.js.snap b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/BannerText.test.js.snap new file mode 100644 index 0000000000..8e84cf4911 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/BannerText.test.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BannerText renders header when props.bannerText is a string 1`] = ` +
+ foo text +
+`; + +exports[`BannerText renders styled header when props.bannerText is a styled value 1`] = ` +
+ foo text +
+`; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/CountCard.test.js.snap b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/CountCard.test.js.snap new file mode 100644 index 0000000000..0853a27fa1 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/CountCard.test.js.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CountCard renders as expected when given count and title 1`] = ` +
+ + Test Title + + + 108 + + +
+`; + +exports[`CountCard renders as expected when given count, title, and examples 1`] = ` +
+ + Test Title + + + 108 + + +
+`; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/ExamplesLink.test.js.snap b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/ExamplesLink.test.js.snap new file mode 100644 index 0000000000..20762163b9 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/ExamplesLink.test.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExamplesLink renders as expected when given both span and trace links 1`] = ` + + + +`; + +exports[`ExamplesLink renders as expected when given span links 1`] = ` + + + +`; + +exports[`ExamplesLink renders as expected when given trace links 1`] = ` + + + +`; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/Header.test.js.snap b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/Header.test.js.snap new file mode 100644 index 0000000000..0c375f82ea --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/Header.test.js.snap @@ -0,0 +1,75 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Header rendering renders as expected with full props 1`] = ` +
+ + + + + (in hours) + +
+`; + +exports[`Header rendering renders as expected with minimum props 1`] = ` +
+ + + + + (in hours) + +
+`; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/MetricCard.test.js.snap b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/MetricCard.test.js.snap new file mode 100644 index 0000000000..d93121f7a2 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/MetricCard.test.js.snap @@ -0,0 +1,374 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MetricCard renders as expected when passCount is zero 1`] = ` +
+
+ +
+
+ + Metric Name + + + + + + + +

+ Metric Description +

+
+ + + +
+
+
+`; + +exports[`MetricCard renders as expected with details 1`] = ` +
+
+ +
+
+ + Metric Name + + + + + + + +

+ Metric Description +

+
+ + + +
+ + +
+
+`; + +exports[`MetricCard renders as expected without details 1`] = ` +
+
+ +
+
+ + Metric Name + + + + + + + +

+ Metric Description +

+
+ + + +
+
+
+`; + +exports[`MetricCard renders as expected without details 2`] = ` +
+
+ +
+
+ + Metric Name + + + + + + + +

+ Metric Description +

+
+ + + +
+
+
+`; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/ScoreCard.test.js.snap b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/ScoreCard.test.js.snap new file mode 100644 index 0000000000..448f6364b9 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/ScoreCard.test.js.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ScoreCard renders as expected when score is below max 1`] = ` +
+ + Test Score + +
+ +
+ + How to improve + + +
+`; + +exports[`ScoreCard renders as expected when score is max 1`] = ` +
+ + Test Score + +
+ +
+ + Great! What does this mean + + +
+`; + +exports[`ScoreCard renders as expected when score is zero 1`] = ` +
+ + Test Score + +
+ +
+ + How to improve + + +
+`; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/url.test.js b/packages/jaeger-ui/src/components/QualityMetrics/url.test.js new file mode 100644 index 0000000000..2a8d777010 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/url.test.js @@ -0,0 +1,97 @@ +// 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 reactRouterDom from 'react-router-dom'; + +import { ROUTE_PATH, matches, getUrl, getUrlState } from './url'; + +describe('TraceDiff/url', () => { + const lookback = 42; + const service = 'test-service'; + + describe('matches', () => { + const path = 'path argument'; + let matchPathSpy; + + beforeAll(() => { + matchPathSpy = jest.spyOn(reactRouterDom, 'matchPath'); + }); + + it('calls matchPath with expected arguments', () => { + matches(path); + expect(matchPathSpy).toHaveBeenLastCalledWith(path, { + path: ROUTE_PATH, + strict: true, + exact: true, + }); + }); + + it("returns truthiness of matchPath's return value", () => { + matchPathSpy.mockReturnValueOnce(null); + expect(matches(path)).toBe(false); + matchPathSpy.mockReturnValueOnce({}); + expect(matches(path)).toBe(true); + }); + }); + + describe('getUrl', () => { + it('handles an absent param arg', () => { + expect(getUrl()).toBe(ROUTE_PATH); + }); + + it('handles param arg', () => { + const arg = { + lookback, + service, + }; + expect(getUrl(arg)).toBe(`${ROUTE_PATH}?lookback=${arg.lookback}&service=${arg.service}`); + }); + }); + + describe('getUrlState', () => { + const defaultState = { + lookback: 48, + }; + + it('defaults lookback to 48h', () => { + expect(getUrlState('')).toEqual(defaultState); + }); + + it('parses lookback from url', () => { + expect(getUrlState(`?lookback=${lookback}`)).toEqual({ + lookback, + }); + }); + + it('parses first lookback in url', () => { + expect(getUrlState(`?lookback=${lookback}&lookback="second unused lookback value"`)).toEqual({ + lookback, + }); + }); + + it('gets service from url', () => { + expect(getUrlState(`?service=${service}`)).toEqual({ + ...defaultState, + service, + }); + }); + + it('uses first service in url', () => { + expect(getUrlState(`?service=${service}&service="second unused service value"`)).toEqual({ + ...defaultState, + service, + }); + }); + }); +}); diff --git a/packages/jaeger-ui/src/components/QualityMetrics/url.tsx b/packages/jaeger-ui/src/components/QualityMetrics/url.tsx index 71031db32c..e4608b0823 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/url.tsx +++ b/packages/jaeger-ui/src/components/QualityMetrics/url.tsx @@ -43,7 +43,7 @@ export const getUrlState = memoizeOne(function getUrlState(search: string): TRet const lookbackStr = Array.isArray(lookbackFromUrl) ? lookbackFromUrl[0] : lookbackFromUrl; const lookback = lookbackStr && Number.parseInt(lookbackStr, 10); const rv: TReturnValue = { - lookback: 1, + lookback: 48, }; if (service) rv.service = service; if (lookback) rv.lookback = lookback; diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.js index e116990b4c..780f35acd3 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.js @@ -15,8 +15,8 @@ import React from 'react'; import { shallow } from 'enzyme'; +import VerticalResizer from '../../../common/VerticalResizer'; import TimelineHeaderRow from './TimelineHeaderRow'; -import TimelineColumnResizer from './TimelineColumnResizer'; import TimelineViewingLayer from './TimelineViewingLayer'; import Ticks from '../Ticks'; import TimelineCollapser from './TimelineCollapser'; @@ -86,9 +86,9 @@ describe('', () => { expect(wrapper.containsMatchingElement(elm)).toBe(true); }); - it('renders the TimelineColumnResizer', () => { + it('renders the VerticalResizer', () => { const elm = ( - - + ); } diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.css b/packages/jaeger-ui/src/components/common/VerticalResizer.css similarity index 55% rename from packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.css rename to packages/jaeger-ui/src/components/common/VerticalResizer.css index 6e00ed0cc7..b5a7352bb6 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.css +++ b/packages/jaeger-ui/src/components/common/VerticalResizer.css @@ -14,24 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -.TimelineColumnResizer { +.VerticalResizer { left: 0; position: absolute; right: 0; top: 0; } -.TimelineColumnResizer.is-flipped { +.VerticalResizer.is-flipped { transform: scaleX(-1); } -.TimelineColumnResizer--wrapper { +.VerticalResizer--wrapper { bottom: 0; position: absolute; top: 0; } -.TimelineColumnResizer--dragger { +.VerticalResizer--dragger { border-left: 2px solid transparent; cursor: col-resize; height: calc(100vh - var(--nav-height)); @@ -41,27 +41,27 @@ limitations under the License. width: 1px; } -.TimelineColumnResizer--dragger:hover { +.VerticalResizer--dragger:hover { border-left: 2px solid rgba(0, 0, 0, 0.3); } -.TimelineColumnResizer.isDraggingLeft > .TimelineColumnResizer--dragger, -.TimelineColumnResizer.isDraggingRight > .TimelineColumnResizer--dragger { +.VerticalResizer.isDraggingLeft > .VerticalResizer--dragger, +.VerticalResizer.isDraggingRight > .VerticalResizer--dragger { background: rgba(136, 0, 136, 0.05); width: unset; } -.TimelineColumnResizer.isDraggingLeft > .TimelineColumnResizer--dragger { +.VerticalResizer.isDraggingLeft > .VerticalResizer--dragger { border-left: 2px solid #808; border-right: 1px solid #999; } -.TimelineColumnResizer.isDraggingRight > .TimelineColumnResizer--dragger { +.VerticalResizer.isDraggingRight > .VerticalResizer--dragger { border-left: 1px solid #999; border-right: 2px solid #808; } -.TimelineColumnResizer--dragger::before { +.VerticalResizer--dragger::before { position: absolute; top: 0; bottom: 0; @@ -70,20 +70,20 @@ limitations under the License. content: ' '; } -.TimelineColumnResizer.isDraggingLeft > .TimelineColumnResizer--dragger::before, -.TimelineColumnResizer.isDraggingRight > .TimelineColumnResizer--dragger::before { +.VerticalResizer.isDraggingLeft > .VerticalResizer--dragger::before, +.VerticalResizer.isDraggingRight > .VerticalResizer--dragger::before { left: -2000px; right: -2000px; } -.TimelineColumnResizer--gripIcon { +.VerticalResizer--gripIcon { position: absolute; top: 0; bottom: 0; } -.TimelineColumnResizer--gripIcon::before, -.TimelineColumnResizer--gripIcon::after { +.VerticalResizer--gripIcon::before, +.VerticalResizer--gripIcon::after { border-right: 1px solid #ccc; content: ' '; height: 9px; @@ -92,13 +92,13 @@ limitations under the License. top: 25px; } -.TimelineColumnResizer--gripIcon::after { +.VerticalResizer--gripIcon::after { right: 5px; } -.TimelineColumnResizer.isDraggingLeft > .TimelineColumnResizer--gripIcon::before, -.TimelineColumnResizer.isDraggingRight > .TimelineColumnResizer--gripIcon::before, -.TimelineColumnResizer.isDraggingLeft > .TimelineColumnResizer--gripIcon::after, -.TimelineColumnResizer.isDraggingRight > .TimelineColumnResizer--gripIcon::after { +.VerticalResizer.isDraggingLeft > .VerticalResizer--gripIcon::before, +.VerticalResizer.isDraggingRight > .VerticalResizer--gripIcon::before, +.VerticalResizer.isDraggingLeft > .VerticalResizer--gripIcon::after, +.VerticalResizer.isDraggingRight > .VerticalResizer--gripIcon::after { border-right: 1px solid rgba(136, 0, 136, 0.5); } diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js b/packages/jaeger-ui/src/components/common/VerticalResizer.test.js similarity index 87% rename from packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js rename to packages/jaeger-ui/src/components/common/VerticalResizer.test.js index 693f7a7d8a..7be1a417a9 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js +++ b/packages/jaeger-ui/src/components/common/VerticalResizer.test.js @@ -15,9 +15,9 @@ import React from 'react'; import { mount } from 'enzyme'; -import TimelineColumnResizer from './TimelineColumnResizer'; +import VerticalResizer from './VerticalResizer'; -describe('', () => { +describe('', () => { let wrapper; let instance; @@ -30,19 +30,19 @@ describe('', () => { beforeEach(() => { props.onChange.mockReset(); - wrapper = mount(); + wrapper = mount(); instance = wrapper.instance(); }); it('renders without exploding', () => { expect(wrapper).toBeDefined(); - expect(wrapper.find('.TimelineColumnResizer').length).toBe(1); - expect(wrapper.find('.TimelineColumnResizer--gripIcon').length).toBe(1); - expect(wrapper.find('.TimelineColumnResizer--dragger').length).toBe(1); + expect(wrapper.find('.VerticalResizer').length).toBe(1); + expect(wrapper.find('.VerticalResizer--gripIcon').length).toBe(1); + expect(wrapper.find('.VerticalResizer--dragger').length).toBe(1); }); it('sets the root elm', () => { - const rootWrapper = wrapper.find('.TimelineColumnResizer'); + const rootWrapper = wrapper.find('.VerticalResizer'); expect(rootWrapper.getDOMNode()).toBe(instance._rootElm); }); @@ -50,7 +50,7 @@ describe('', () => { it('handles mouse down on the dragger', () => { const dragger = wrapper.find({ onMouseDown: instance._dragManager.handleMouseDown }); expect(dragger.length).toBe(1); - expect(dragger.is('.TimelineColumnResizer--dragger')).toBe(true); + expect(dragger.is('.VerticalResizer--dragger')).toBe(true); }); it('returns the draggable bounds via _getDraggingBounds()', () => { @@ -135,7 +135,7 @@ describe('', () => { it('does not render a dragging indicator when not dragging', () => { expect(wrapper.find('.isDraggingLeft').length + wrapper.find('.isDraggingRight').length).toBe(0); - expect(wrapper.find('.TimelineColumnResizer--dragger').prop('style').right).toBe(undefined); + expect(wrapper.find('.VerticalResizer--dragger').prop('style').right).toBe(undefined); }); it('renders a dragging indicator when dragging', () => { @@ -144,7 +144,7 @@ describe('', () => { instance.forceUpdate(); wrapper.update(); expect(wrapper.find('.isDraggingLeft').length + wrapper.find('.isDraggingRight').length).toBe(1); - expect(wrapper.find('.TimelineColumnResizer--dragger').prop('style').right).toBeDefined(); + expect(wrapper.find('.VerticalResizer--dragger').prop('style').right).toBeDefined(); }); it('renders is-flipped classname when positioned on rightSide', () => { diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx b/packages/jaeger-ui/src/components/common/VerticalResizer.tsx similarity index 84% rename from packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx rename to packages/jaeger-ui/src/components/common/VerticalResizer.tsx index 246e3a8600..9adb883793 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx +++ b/packages/jaeger-ui/src/components/common/VerticalResizer.tsx @@ -15,12 +15,12 @@ import * as React from 'react'; import cx from 'classnames'; -import { TNil } from '../../../../types'; -import DraggableManager, { DraggableBounds, DraggingUpdate } from '../../../../utils/DraggableManager'; +import { TNil } from '../../types'; +import DraggableManager, { DraggableBounds, DraggingUpdate } from '../../utils/DraggableManager'; -import './TimelineColumnResizer.css'; +import './VerticalResizer.css'; -type TimelineColumnResizerProps = { +type VerticalResizerProps = { max: number; min: number; onChange: (newSize: number) => void; @@ -28,20 +28,17 @@ type TimelineColumnResizerProps = { rightSide?: boolean; }; -type TimelineColumnResizerState = { +type VerticalResizerState = { dragPosition: number | TNil; }; -export default class TimelineColumnResizer extends React.PureComponent< - TimelineColumnResizerProps, - TimelineColumnResizerState -> { - state: TimelineColumnResizerState; +export default class VerticalResizer extends React.PureComponent { + state: VerticalResizerState; _dragManager: DraggableManager; _rootElm: Element | TNil; - constructor(props: TimelineColumnResizerProps) { + constructor(props: VerticalResizerProps) { super(props); this._dragManager = new DraggableManager({ getBounds: this._getDraggingBounds, @@ -119,13 +116,13 @@ export default class TimelineColumnResizer extends React.PureComponent< } return (
-
+
From 97c959bf109d9f92e95e7b818851787fdb28631b Mon Sep 17 00:00:00 2001 From: Everett Ross Date: Wed, 13 May 2020 13:37:19 -0400 Subject: [PATCH 03/12] Add key to metric details, test QualityMetrics Signed-off-by: Everett Ross --- .../components/QualityMetrics/Header.test.js | 2 +- .../components/QualityMetrics/MetricCard.tsx | 1 + .../__snapshots__/MetricCard.test.js.snap | 2 + .../__snapshots__/index.test.js.snap | 138 ++++++++ .../components/QualityMetrics/index.test.js | 294 ++++++++++++++++++ .../src/components/QualityMetrics/index.tsx | 7 +- 6 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/index.test.js.snap create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/index.test.js diff --git a/packages/jaeger-ui/src/components/QualityMetrics/Header.test.js b/packages/jaeger-ui/src/components/QualityMetrics/Header.test.js index 4fc398ac0f..9421da27d2 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/Header.test.js +++ b/packages/jaeger-ui/src/components/QualityMetrics/Header.test.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// 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. diff --git a/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx b/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx index 82dad7c4d4..113c6f9dfc 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx +++ b/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx @@ -80,6 +80,7 @@ export default class MetricCard extends React.PureComponent { detail => Boolean(detail.rows && detail.rows.length) && (
diff --git a/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..54a3975b79 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/index.test.js.snap @@ -0,0 +1,138 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`QualityMetrics UnconnectedQualityMetrics render renders when errored 1`] = ` +
+
+
+ Error message +
+
+`; + +exports[`QualityMetrics UnconnectedQualityMetrics render renders when loading 1`] = ` +
+
+ +
+`; + +exports[`QualityMetrics UnconnectedQualityMetrics render renders with metrics 1`] = ` +
+
+ +
+
+ + +
+
+ + +
+
+
+`; + +exports[`QualityMetrics UnconnectedQualityMetrics render renders without loading, error, or metrics 1`] = ` +
+
+
+`; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/index.test.js b/packages/jaeger-ui/src/components/QualityMetrics/index.test.js new file mode 100644 index 0000000000..7dbda14e59 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/index.test.js @@ -0,0 +1,294 @@ +// 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 JaegerAPI from '../../api/jaeger'; +import Header from './Header'; +import * as getUrl from './url'; +import { UnconnectedQualityMetrics, mapDispatchToProps, mapStateToProps } from '.'; + +describe('QualityMetrics', () => { + describe('UnconnectedQualityMetrics', () => { + const props = { + fetchServices: jest.fn(), + history: { + push: jest.fn(), + }, + lookback: 48, + service: 'test-service', + services: ['foo', 'bar', 'baz'], + }; + const { service: _s, ...propsWithoutService } = props; + let fetchQualityMetricsSpy; + let promise; + let res; + let rej; + + beforeAll(() => { + fetchQualityMetricsSpy = jest.spyOn(JaegerAPI, 'fetchQualityMetrics').mockImplementation(() => { + promise = new Promise((resolve, reject) => { + res = resolve; + rej = reject; + }); + return promise; + }); + }); + + beforeEach(() => { + props.history.push.mockClear(); + props.fetchServices.mockClear(); + fetchQualityMetricsSpy.mockClear(); + }); + + describe('constructor', () => { + it('fetches services if none are provided', () => { + const { services: _ses, ...propsWithoutServices } = props; + // eslint-disable-next-line no-new + new UnconnectedQualityMetrics(propsWithoutServices); + expect(props.fetchServices).toHaveBeenCalledTimes(1); + }); + + it('no-ops if services are provided', () => { + // eslint-disable-next-line no-new + new UnconnectedQualityMetrics(props); + expect(props.fetchServices).not.toHaveBeenCalled(); + }); + }); + + describe('componentDidMount', () => { + it('fetches quality metrics', () => { + shallow(); + expect(fetchQualityMetricsSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('componentDidUpdate', () => { + const expectedState = { + qualityMetrics: undefined, + error: undefined, + loading: true, + }; + const initialState = { + qualityMetrics: { + scores: [], + metrics: [], + }, + error: {}, + loading: false, + }; + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + wrapper.setState(initialState); + }); + + // TODO state + it('clears state and fetches quality metrics if service changed', () => { + expect(fetchQualityMetricsSpy).toHaveBeenCalledTimes(1); + wrapper.setProps({ service: `not-${props.service}` }); + expect(fetchQualityMetricsSpy).toHaveBeenCalledTimes(2); + expect(wrapper.state()).toEqual(expectedState); + }); + + it('clears state and fetches quality metrics if changed', () => { + expect(fetchQualityMetricsSpy).toHaveBeenCalledTimes(1); + wrapper.setProps({ lookback: `not-${props.lookback}` }); + expect(fetchQualityMetricsSpy).toHaveBeenCalledTimes(2); + expect(wrapper.state()).toEqual(expectedState); + }); + + it('no-ops if neither service or lookback changed', () => { + expect(fetchQualityMetricsSpy).toHaveBeenCalledTimes(1); + wrapper.setProps({ services: [] }); + expect(fetchQualityMetricsSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('fetches quality metrics', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('no-ops on falsy service', () => { + expect(wrapper.state('loading')).toBe(undefined); + }); + + it('fetches quality metrics and updates state on success', async () => { + wrapper.setProps({ service: props.service }); + expect(wrapper.state('loading')).toBe(true); + + const qualityMetrics = {}; + res(qualityMetrics); + await promise; + + expect(wrapper.state('loading')).toBe(false); + expect(wrapper.state('qualityMetrics')).toBe(qualityMetrics); + }); + + it('fetches quality metrics and updates state on error', async () => { + wrapper.setProps({ service: props.service }); + expect(wrapper.state('loading')).toBe(true); + + const error = {}; + rej(error); + await promise.catch(() => {}); + + expect(wrapper.state('loading')).toBe(false); + expect(wrapper.state('error')).toBe(error); + }); + }); + + describe('url updates', () => { + const testLookback = props.lookback * 4; + const testUrl = 'test.url'; + let getUrlSpy; + let latestUrl; + let setLookback; + let wrapper; + + beforeAll(() => { + getUrlSpy = jest.spyOn(getUrl, 'getUrl').mockImplementation(() => { + latestUrl = `${testUrl}.${getUrlSpy.mock.calls.length}`; + return latestUrl; + }); + wrapper = shallow(); + setLookback = wrapper.find(Header).prop('setLookback'); + }); + + it('sets service', () => { + const testService = `new ${props.service}`; + wrapper.find(Header).prop('setService')(testService); + expect(getUrlSpy).toHaveBeenLastCalledWith({ + lookback: props.lookback, + service: testService, + }); + expect(props.history.push).toHaveBeenLastCalledWith(latestUrl); + }); + + it('sets lookback', () => { + setLookback(testLookback); + expect(getUrlSpy).toHaveBeenLastCalledWith({ + lookback: testLookback, + service: props.service, + }); + expect(props.history.push).toHaveBeenLastCalledWith(latestUrl); + }); + + it('sets lookback without service', () => { + wrapper.setProps({ service: undefined }); + setLookback(testLookback); + expect(getUrlSpy).toHaveBeenLastCalledWith({ + lookback: testLookback, + service: '', + }); + expect(props.history.push).toHaveBeenLastCalledWith(latestUrl); + }); + + it('ignores falsy, string, less than one, and fractional lookbacks', () => { + setLookback(null); + expect(props.history.push).not.toHaveBeenCalled(); + + setLookback(props.service); + expect(props.history.push).not.toHaveBeenCalled(); + + setLookback(-1); + expect(props.history.push).not.toHaveBeenCalled(); + + setLookback(2.5); + expect(props.history.push).not.toHaveBeenCalled(); + }); + }); + + describe('render', () => { + it('renders without loading, error, or metrics', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('renders when loading', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('renders when errored', async () => { + const wrapper = shallow(); + const message = 'Error message'; + rej({ message }); + await promise.catch(() => {}); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders with metrics', async () => { + const wrapper = shallow(); + const metrics = { + bannerText: 'test banner text', + traceQualityDocumentationLink: 'trace.quality.documentation/link', + scores: [ + { + key: 'score0', + }, + { + key: 'score1', + }, + ], + metrics: [ + { + name: 'metric 0', + }, + { + name: 'metric 1', + }, + ], + }; + res(metrics); + await promise; + expect(wrapper).toMatchSnapshot(); + }); + }); + }); + + describe('mapDispatchToProps()', () => { + it('creates the actions correctly', () => { + expect(mapDispatchToProps(() => {})).toEqual({ + fetchServices: expect.any(Function), + }); + }); + }); + + describe('mapStateToProps()', () => { + let getUrlStateSpy; + const urlState = { + lookback: 108, + service: 'test-service', + }; + + beforeAll(() => { + getUrlStateSpy = jest.spyOn(getUrl, 'getUrlState').mockImplementation(search => search && urlState); + }); + + it('gets services from redux state', () => { + const services = [urlState.service, 'foo', 'bar']; + expect(mapStateToProps({ services: { services } }, { location: {} })).toEqual({ services }); + }); + + it('gets current service and lookback from url', () => { + const search = 'test search'; + expect(mapStateToProps({ services: {} }, { location: { search } })).toEqual(urlState); + expect(getUrlStateSpy).toHaveBeenLastCalledWith(search); + }); + }); +}); diff --git a/packages/jaeger-ui/src/components/QualityMetrics/index.tsx b/packages/jaeger-ui/src/components/QualityMetrics/index.tsx index 46e7bd7af3..12748be1d7 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/index.tsx +++ b/packages/jaeger-ui/src/components/QualityMetrics/index.tsx @@ -90,7 +90,7 @@ export class UnconnectedQualityMetrics extends React.PureComponent { this.setState({ qualityMetrics, loading: false }); }) @@ -195,8 +195,9 @@ export class UnconnectedQualityMetrics extends React.PureComponent Date: Thu, 14 May 2020 16:22:11 -0400 Subject: [PATCH 04/12] Move ExamplesLink&DetailsCard, split&test DC Signed-off-by: Everett Ross --- .../SidePanel/DetailsCard/index.tsx | 235 --------------- .../SidePanel/DetailsPanel.tsx | 16 +- .../components/QualityMetrics/CountCard.tsx | 4 +- .../components/QualityMetrics/MetricCard.tsx | 2 +- .../components/QualityMetrics/index.test.js | 1 - .../src/components/QualityMetrics/index.tsx | 4 +- .../src/components/QualityMetrics/types.tsx | 7 +- .../common/DetailsCard/DetailList.test.js | 34 +++ .../common/DetailsCard/DetailList.tsx | 31 ++ .../common/DetailsCard/DetailTable.test.js | 281 ++++++++++++++++++ .../common/DetailsCard/DetailTable.tsx | 147 +++++++++ .../__snapshots__/DetailList.test.js.snap | 29 ++ .../__snapshots__/DetailTable.test.js.snap | 207 +++++++++++++ .../__snapshots__/index.test.js.snap | 242 +++++++++++++++ .../DetailsCard/index.css | 0 .../common/DetailsCard/index.test.js | 74 +++++ .../components/common/DetailsCard/index.tsx | 97 ++++++ .../components/common/DetailsCard/types.tsx | 34 +++ .../ExamplesLink.test.js | 0 .../ExamplesLink.tsx | 9 +- .../__snapshots__/ExamplesLink.test.js.snap | 0 .../model/path-agnostic-decorations/types.tsx | 24 -- 22 files changed, 1197 insertions(+), 281 deletions(-) delete mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.tsx create mode 100644 packages/jaeger-ui/src/components/common/DetailsCard/DetailList.test.js create mode 100644 packages/jaeger-ui/src/components/common/DetailsCard/DetailList.tsx create mode 100644 packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js create mode 100644 packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx create mode 100644 packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailList.test.js.snap create mode 100644 packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailTable.test.js.snap create mode 100644 packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/index.test.js.snap rename packages/jaeger-ui/src/components/{DeepDependencies/SidePanel => common}/DetailsCard/index.css (100%) create mode 100644 packages/jaeger-ui/src/components/common/DetailsCard/index.test.js create mode 100644 packages/jaeger-ui/src/components/common/DetailsCard/index.tsx create mode 100644 packages/jaeger-ui/src/components/common/DetailsCard/types.tsx rename packages/jaeger-ui/src/components/{QualityMetrics => common}/ExamplesLink.test.js (100%) rename packages/jaeger-ui/src/components/{QualityMetrics => common}/ExamplesLink.tsx (92%) rename packages/jaeger-ui/src/components/{QualityMetrics => common}/__snapshots__/ExamplesLink.test.js.snap (100%) diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.tsx b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.tsx deleted file mode 100644 index 10543f1c97..0000000000 --- a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.tsx +++ /dev/null @@ -1,235 +0,0 @@ -// 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 { List, Table } from 'antd'; -import cx from 'classnames'; -import _isEmpty from 'lodash/isEmpty'; -import MdKeyboardArrowDown from 'react-icons/lib/md/keyboard-arrow-down'; - -import ExamplesLink from '../../../QualityMetrics/ExamplesLink'; - -import { - TExample, - TPadColumnDef, - TPadColumnDefs, - TPadDetails, - TPadRow, - TStyledValue, -} from '../../../../model/path-agnostic-decorations/types'; - -import './index.css'; - -const { Column } = Table; -const { Item } = List; - -type TProps = { - className?: string; - collapsible?: boolean; - columnDefs?: TPadColumnDefs; - description?: string; - details: TPadDetails; - header: string; -}; - -type TState = { - collapsed: boolean; -}; - -function isList(arr: string[] | TPadRow[]): arr is string[] { - return typeof arr[0] === 'string'; -} - -export default class DetailsCard extends React.PureComponent { - state: TState; - - static renderList(details: string[]) { - return ( - ( - - {s} - - )} - /> - ); - } - - static renderColumn(def: TPadColumnDef | string) { - let dataIndex: string; - let key: string; - let sortable: boolean = true; - let style: React.CSSProperties | undefined; - let title: string; - if (typeof def === 'string') { - // eslint-disable-next-line no-multi-assign - key = title = dataIndex = def; - } else { - // eslint-disable-next-line no-multi-assign - key = title = dataIndex = def.key; - if (def.label) title = def.label; - if (def.styling) style = def.styling; - if (def.preventSort) sortable = false; - } - - const props = { - dataIndex, - key, - title, - onCell: (row: TPadRow) => { - const cellData = row[dataIndex]; - if (!cellData || typeof cellData !== 'object' || Array.isArray(cellData)) return null; - const { styling } = cellData; - if (_isEmpty(styling)) return null; - return { - style: styling, - }; - }, - onHeaderCell: () => ({ - style, - }), - render: (cellData: undefined | string | TStyledValue) => { - if (!cellData || typeof cellData !== 'object') return cellData; - if (Array.isArray(cellData)) return ; - if (!cellData.linkTo) return cellData.value; - return ( - - {cellData.value} - - ); - }, - sorter: - sortable && - ((a: TPadRow, b: TPadRow) => { - const aData = a[dataIndex]; - let aValue; - if (Array.isArray(aData)) aValue = aData.length; - else if (typeof aData === 'object' && typeof aData.value === 'string') aValue = aData.value; - else aValue = aData; - - const bData = b[dataIndex]; - let bValue; - if (Array.isArray(bData)) bValue = bData.length; - else if (typeof bData === 'object' && typeof bData.value === 'string') bValue = bData.value; - else bValue = bData; - - if (aValue < bValue) return -1; - return bValue < aValue ? 1 : 0; - }), - }; - - return ; - } - - constructor(props: TProps) { - super(props); - - this.state = { collapsed: Boolean(props.collapsible) }; - } - - renderTable(details: TPadRow[]) { - const { columnDefs: _columnDefs } = this.props; - const columnDefs: TPadColumnDefs = _columnDefs ? _columnDefs.slice() : []; - const knownColumns = new Set( - columnDefs.map(keyOrObj => { - if (typeof keyOrObj === 'string') return keyOrObj; - return keyOrObj.key; - }) - ); - details.forEach(row => { - Object.keys(row).forEach((col: string) => { - if (!knownColumns.has(col)) { - knownColumns.add(col); - columnDefs.push(col); - } - }); - }); - - return ( - - JSON.stringify(row, function replacer( - key: string, - value: TPadRow | string | number | TStyledValue | TExample[] - ) { - function isRow(v: typeof value): v is TPadRow { - return v === row; - } - if (isRow(value)) return value; - if (Array.isArray(value)) return JSON.stringify(value); - if (typeof value === 'object') { - if (typeof value.value === 'string') return value.value; - return value.value.key || 'Unknown'; - } - return value; - }) - } - > - {columnDefs.map(DetailsCard.renderColumn)} -
- ); - } - - renderDetails() { - const { details } = this.props; - - if (Array.isArray(details)) { - if (details.length === 0) return null; - - if (isList(details)) return DetailsCard.renderList(details); - return this.renderTable(details); - } - - return {details}; - } - - toggleCollapse = () => { - this.setState((prevState: TState) => ({ - collapsed: !prevState.collapsed, - })); - }; - - render() { - const { collapsed } = this.state; - const { className, collapsible, description, header } = this.props; - - return ( -
-
- {collapsible && ( - - )} -
- {header} - {description &&

{description}

} -
-
-
- {this.renderDetails()} -
-
- ); - } -} diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx index 9cb96c9c6f..68e84c5ac3 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx @@ -18,18 +18,16 @@ import _get from 'lodash/get'; import { connect } from 'react-redux'; import BreakableText from '../../common/BreakableText'; +import DetailsCard from '../../common/DetailsCard'; import LoadingIndicator from '../../common/LoadingIndicator'; import NewWindowIcon from '../../common/NewWindowIcon'; import VerticalResizer from '../../common/VerticalResizer'; import JaegerAPI from '../../../api/jaeger'; import extractDecorationFromState, { TDecorationFromState } from '../../../model/path-agnostic-decorations'; -import { - TPathAgnosticDecorationSchema, - TPadColumnDefs, - TPadDetails, -} from '../../../model/path-agnostic-decorations/types'; import stringSupplant from '../../../utils/stringSupplant'; -import DetailsCard from './DetailsCard'; + +import { TPathAgnosticDecorationSchema } from '../../../model/path-agnostic-decorations/types'; +import { TColumnDefs, TDetails } from '../../common/DetailsCard/types'; import './DetailsPanel.css'; @@ -40,8 +38,8 @@ type TProps = TDecorationFromState & { }; type TState = { - columnDefs?: TPadColumnDefs; - details?: TPadDetails; + columnDefs?: TColumnDefs; + details?: TDetails; detailsErred?: boolean; detailsLoading?: boolean; width?: number; @@ -111,7 +109,7 @@ export class UnconnectedDetailsPanel extends React.PureComponent details = `\`${getDetailPath}\` not found in response`; detailsErred = true; } - const columnDefs: TPadColumnDefs = getDefPath ? _get(res, getDefPath, []) : []; + const columnDefs: TColumnDefs = getDefPath ? _get(res, getDefPath, []) : []; this.setState({ columnDefs, details, detailsErred, detailsLoading: false }); }) diff --git a/packages/jaeger-ui/src/components/QualityMetrics/CountCard.tsx b/packages/jaeger-ui/src/components/QualityMetrics/CountCard.tsx index 9ba6033a8d..8345b20f81 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/CountCard.tsx +++ b/packages/jaeger-ui/src/components/QualityMetrics/CountCard.tsx @@ -14,9 +14,7 @@ import * as React from 'react'; -import ExamplesLink from './ExamplesLink'; - -import { TExample } from '../../model/path-agnostic-decorations/types'; +import ExamplesLink, { TExample } from '../common/ExamplesLink'; import './CountCard.css'; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx b/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx index 113c6f9dfc..6ac7d7bd3a 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx +++ b/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx @@ -17,7 +17,7 @@ import { Tooltip } from 'antd'; import CircularProgressbar from '../common/CircularProgressbar'; import NewWindowIcon from '../common/NewWindowIcon'; -import DetailsCard from '../DeepDependencies/SidePanel/DetailsCard'; +import DetailsCard from '../common/DetailsCard'; import CountCard from './CountCard'; import { TQualityMetrics } from './types'; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/index.test.js b/packages/jaeger-ui/src/components/QualityMetrics/index.test.js index 7dbda14e59..c6ee95487a 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/index.test.js +++ b/packages/jaeger-ui/src/components/QualityMetrics/index.test.js @@ -96,7 +96,6 @@ describe('QualityMetrics', () => { wrapper.setState(initialState); }); - // TODO state it('clears state and fetches quality metrics if service changed', () => { expect(fetchQualityMetricsSpy).toHaveBeenCalledTimes(1); wrapper.setProps({ service: `not-${props.service}` }); diff --git a/packages/jaeger-ui/src/components/QualityMetrics/index.tsx b/packages/jaeger-ui/src/components/QualityMetrics/index.tsx index 12748be1d7..a13dbec670 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/index.tsx +++ b/packages/jaeger-ui/src/components/QualityMetrics/index.tsx @@ -20,9 +20,9 @@ import { bindActionCreators, Dispatch } from 'redux'; import * as jaegerApiActions from '../../actions/jaeger-api'; import JaegerAPI from '../../api/jaeger'; import LoadingIndicator from '../common/LoadingIndicator'; -import DetailsCard from '../DeepDependencies/SidePanel/DetailsCard'; +import DetailsCard from '../common/DetailsCard'; +import ExamplesLink from '../common/ExamplesLink'; import BannerText from './BannerText'; -import ExamplesLink from './ExamplesLink'; import Header from './Header'; import MetricCard from './MetricCard'; import ScoreCard from './ScoreCard'; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/types.tsx b/packages/jaeger-ui/src/components/QualityMetrics/types.tsx index 3509233869..202889ec07 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/types.tsx +++ b/packages/jaeger-ui/src/components/QualityMetrics/types.tsx @@ -14,7 +14,8 @@ import * as React from 'react'; -import { TExample, TPadColumnDef, TPadRow } from '../../model/path-agnostic-decorations/types'; +import { TExample } from '../common/ExamplesLink'; +import { TColumnDef, TRow } from '../common/DetailsCard/types'; export type TQualityMetrics = { traceQualityDocumentationLink: string; @@ -45,8 +46,8 @@ export type TQualityMetrics = { details?: { description?: string; header?: string; - columns: TPadColumnDef[]; - rows: TPadRow[]; + columns: TColumnDef[]; + rows: TRow[]; }[]; }[]; clients?: { diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailList.test.js b/packages/jaeger-ui/src/components/common/DetailsCard/DetailList.test.js new file mode 100644 index 0000000000..0b2208326c --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailList.test.js @@ -0,0 +1,34 @@ +// 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 { List } from 'antd'; + +import DetailList from './DetailList'; + +describe('DetailList', () => { + const details = ['foo', 'bar', 'baz']; + + it('renders list as expected', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('renders item as expected', () => { + const renderItem = shallow() + .find(List) + .prop('renderItem'); + expect(shallow(
{renderItem(details[0])}
)).toMatchSnapshot(); + }); +}); diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailList.tsx b/packages/jaeger-ui/src/components/common/DetailsCard/DetailList.tsx new file mode 100644 index 0000000000..73588149c8 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailList.tsx @@ -0,0 +1,31 @@ +// 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 { List } from 'antd'; + +const { Item } = List; + +export default function DetailList({ details }: { details: string[] }) { + return ( + ( + + {s} + + )} + /> + ); +} diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js new file mode 100644 index 0000000000..96e6ad349b --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js @@ -0,0 +1,281 @@ +// 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 ExamplesLink from '../ExamplesLink'; +import DetailTable, { _onCell, _makeColumns, _renderCell, _rowKey, _sort } from './DetailTable'; + +describe('DetailTable', () => { + describe('render', () => { + const col0 = { + styling: { + background: 'red', + color: 'white', + }, + key: 'col0', + }; + const col1 = 'col1'; + const row0 = { + [col0.key]: 'val0', + [col1]: 'val1', + }; + const row1 = { + [col0.key]: 'val2', + [col1]: 'val3', + }; + + it('renders given rows and columns', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('infers all columns', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('infers missing columns', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('does not duplicate columns', () => { + expect(shallow()).toMatchSnapshot(); + }); + }); + + describe('_rowKey', () => { + const column = 'col'; + const examples = [{ spanIDs: ['id0', 'id1'], traceID: 'traceID' }]; + + it('handles undefined', () => { + const row = { [column]: undefined }; + expect(_rowKey(row)).toBe(JSON.stringify(row)); + }); + + it('handles array', () => { + const row = { [column]: examples }; + expect(_rowKey(row)).toBe(JSON.stringify({ [column]: JSON.stringify(examples) })); + }); + + it('handles object with string value', () => { + const valueObject = { value: 'test-value' }; + const row = { [column]: valueObject }; + expect(_rowKey(row)).toBe(JSON.stringify({ [column]: JSON.stringify(valueObject) })); + }); + + it('handles object with React.Element value with key', () => { + const key = 'test-key'; + const elem = ; + const row = { [column]: { value: elem } }; + expect(_rowKey(row)).toBe(JSON.stringify({ [column]: key })); + }); + + it('handles object with React.Element value without key', () => { + const elem = ; + const row = { [column]: { value: elem } }; + expect(_rowKey(row)).toBe(JSON.stringify({ [column]: 'Unknown' })); + }); + }); + + describe('_makeColumns', () => { + const stringColumn = 'stringCol'; + + describe('static props', () => { + const makeColumn = def => _makeColumns({ defs: [def] })[0]; + + it('renders string column', () => { + expect(makeColumn(stringColumn)).toEqual({ + dataIndex: stringColumn, + key: stringColumn, + title: stringColumn, + onCell: expect.any(Function), + onHeaderCell: expect.any(Function), + render: expect.any(Function), + sorter: expect.any(Function), + }); + }); + + it('renders object column', () => { + expect(makeColumn({ key: stringColumn })).toEqual({ + dataIndex: stringColumn, + key: stringColumn, + title: stringColumn, + onCell: expect.any(Function), + onHeaderCell: expect.any(Function), + render: expect.any(Function), + sorter: expect.any(Function), + }); + }); + + it('renders object column with label', () => { + const label = `label, not-${stringColumn}`; + expect( + makeColumn({ + key: stringColumn, + label, + }) + ).toEqual({ + dataIndex: stringColumn, + key: stringColumn, + title: label, + onCell: expect.any(Function), + onHeaderCell: expect.any(Function), + render: expect.any(Function), + sorter: expect.any(Function), + }); + }); + + it('renders object column with styling', () => { + const styling = { + background: 'red', + color: 'white', + }; + expect( + makeColumn({ + key: stringColumn, + styling, + }).onHeaderCell().style + ).toBe(styling); + }); + + it('renders object column without sort', () => { + expect( + makeColumn({ + key: stringColumn, + + preventSort: true, + }) + ).toEqual({ + dataIndex: stringColumn, + key: stringColumn, + title: stringColumn, + onCell: expect.any(Function), + onHeaderCell: expect.any(Function), + render: expect.any(Function), + sorter: false, + }); + }); + }); + + describe('function props', () => { + const makeTestFn = fn => (...vals) => + fn(stringColumn)( + ...vals.map(v => ({ + [stringColumn]: v, + })) + ); + + describe('_onCell', () => { + const onCell = makeTestFn(_onCell); + + it('returns null for undefined', () => { + expect(onCell(undefined)).toBe(null); + }); + + it('returns null for string', () => { + expect(onCell('test-string')).toBe(null); + }); + + it('returns null for array', () => { + expect(onCell([])).toBe(null); + }); + + it('returns null for unstyled object', () => { + expect(onCell({})).toBe(null); + expect(onCell({ styling: {} })).toBe(null); + }); + + it('returns styling for styled object', () => { + const styling = { + background: 'red', + color: 'white', + }; + expect(onCell({ styling }).style).toBe(styling); + }); + }); + + describe('_renderCell', () => { + it('renders a string', () => { + expect(_renderCell('a')).toBe('a'); + }); + + it('handles undefined', () => { + expect(_renderCell()).toBe(undefined); + }); + + it("renders an object's value", () => { + const value = 'test-value'; + expect(_renderCell({ value })).toBe(value); + }); + + it('renders ', () => { + const examples = []; + const exampleLink = _renderCell(examples); + expect(exampleLink.type).toBe(ExamplesLink); + expect(exampleLink.props.examples).toBe(examples); + }); + + it('renders a regular link', () => { + expect(_renderCell({ linkTo: 'test.link', value: 'test-value' })).toMatchSnapshot(); + }); + }); + + describe('_sort ', () => { + const sort = makeTestFn(_sort); + + it('sorts strings', () => { + expect(sort('a', 'b')).toBe(-1); + expect(sort('a', 'a')).toBe(0); + expect(sort('b', 'a')).toBe(1); + }); + + it('sorts arrays by length', () => { + expect(sort(new Array(2), new Array(3))).toBe(-1); + expect(sort(new Array(2), new Array(2))).toBe(0); + expect(sort(new Array(3), new Array(2))).toBe(1); + }); + + it('sorts objects with string values', () => { + expect(sort({ value: 'a' }, { value: 'b' })).toBe(-1); + expect(sort({ value: 'a' }, { value: 'a' })).toBe(0); + expect(sort({ value: 'b' }, { value: 'a' })).toBe(1); + }); + + it('handles objects without string values', () => { + expect(() => sort({}, {})).not.toThrow(); + }); + + describe('mixed types', () => { + it('sorts a string and an object', () => { + expect(sort('a', { value: 'b' })).toBe(-1); + expect(sort('a', { value: 'a' })).toBe(0); + expect(sort('b', { value: 'a' })).toBe(1); + }); + + it('sorts an object and an array', () => { + expect(sort({ value: 'a' }, new Array(3))).toBe(0); + expect(sort({ value: 'a' }, new Array(2))).toBe(0); + expect(sort({ value: 'b' }, new Array(2))).toBe(0); + }); + + it('sorts an array and a string', () => { + expect(sort(new Array(2), 'a')).toBe(0); + expect(sort(new Array(2), 'a')).toBe(0); + expect(sort(new Array(3), 'b')).toBe(0); + }); + }); + }); + }); + }); +}); diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx new file mode 100644 index 0000000000..a53b74f941 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx @@ -0,0 +1,147 @@ +// 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 { Table } from 'antd'; +import _isEmpty from 'lodash/isEmpty'; + +import ExamplesLink, { TExample } from '../ExamplesLink'; + +import { TColumnDef, TColumnDefs, TRow, TStyledValue } from './types'; + +// exported for tests +export const _onCell = (dataIndex: string) => (row: TRow) => { + const cellData = row[dataIndex]; + if (!cellData || typeof cellData !== 'object' || Array.isArray(cellData)) return null; + const { styling } = cellData; + if (_isEmpty(styling)) return null; + return { + style: styling, + }; +}; + +// exported for tests +export const _renderCell = (cellData: undefined | string | TStyledValue) => { + if (!cellData || typeof cellData !== 'object') return cellData; + if (Array.isArray(cellData)) return ; + if (!cellData.linkTo) return cellData.value; + return ( + + {cellData.value} + + ); +}; + +// exported for tests +export const _sort = (dataIndex: string) => (a: TRow, b: TRow) => { + const aData = a[dataIndex]; + let aValue; + if (Array.isArray(aData)) aValue = aData.length; + else if (typeof aData === 'object' && typeof aData.value === 'string') aValue = aData.value; + else aValue = aData; + + const bData = b[dataIndex]; + let bValue; + if (Array.isArray(bData)) bValue = bData.length; + else if (typeof bData === 'object' && typeof bData.value === 'string') bValue = bData.value; + else bValue = bData; + + if (aValue < bValue) return -1; + return bValue < aValue ? 1 : 0; +}; + +// exported for tests +export const _makeColumns = ({ defs }: { defs: TColumnDefs }) => + defs.map((def: TColumnDef | string) => { + let dataIndex: string; + let key: string; + let sortable: boolean = true; + let style: React.CSSProperties | undefined; + let title: string; + if (typeof def === 'string') { + // eslint-disable-next-line no-multi-assign + key = title = dataIndex = def; + } else { + // eslint-disable-next-line no-multi-assign + key = title = dataIndex = def.key; + if (def.label) title = def.label; + if (def.styling) style = def.styling; + if (def.preventSort) sortable = false; + } + + return { + dataIndex, + key, + title, + onCell: _onCell(dataIndex), + onHeaderCell: () => ({ + style, + }), + render: _renderCell, + sorter: sortable && _sort(dataIndex), + }; + }); + +// exported for tests +export const _rowKey = (row: TRow) => + JSON.stringify(row, function replacer( + key: string, + value: TRow | undefined | string | number | TStyledValue | TExample[] + ) { + function isRow(v: typeof value): v is TRow { + return v === row; + } + if (isRow(value)) return value; + if (Array.isArray(value)) return JSON.stringify(value); + if (typeof value === 'object') { + if (typeof value.value === 'string') return JSON.stringify(value); + return value.value.key || 'Unknown'; + } + return value; + }); + +export default function DetailTable({ + columnDefs: _columnDefs, + details, +}: { + columnDefs?: TColumnDefs; + details: TRow[]; +}) { + const columnDefs: TColumnDefs = _columnDefs ? _columnDefs.slice() : []; + const knownColumns = new Set( + columnDefs.map(keyOrObj => { + if (typeof keyOrObj === 'string') return keyOrObj; + return keyOrObj.key; + }) + ); + details.forEach(row => { + Object.keys(row).forEach((col: string) => { + if (!knownColumns.has(col)) { + knownColumns.add(col); + columnDefs.push(col); + } + }); + }); + + return ( + + ); +} diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailList.test.js.snap b/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailList.test.js.snap new file mode 100644 index 0000000000..dff11202a5 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailList.test.js.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DetailList renders item as expected 1`] = ` +
+ + + foo + + +
+`; + +exports[`DetailList renders list as expected 1`] = ` + +`; 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 new file mode 100644 index 0000000000..112ecb673e --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailTable.test.js.snap @@ -0,0 +1,207 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DetailTable _makeColumns function props _renderCell renders a regular link 1`] = ` + + test-value + +`; + +exports[`DetailTable render does not duplicate columns 1`] = ` +
+`; + +exports[`DetailTable render infers all columns 1`] = ` +
+`; + +exports[`DetailTable render infers missing columns 1`] = ` +
+`; + +exports[`DetailTable render renders given rows and columns 1`] = ` +
+`; diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..ab0c060df9 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/index.test.js.snap @@ -0,0 +1,242 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DetailsCard handles empty details array 1`] = ` +
+
+
+ + Details Card Header + +
+
+
+
+`; + +exports[`DetailsCard renders as collapsible 1`] = ` +
+
+ +
+ + Details Card Header + +
+
+
+ +
+
+`; + +exports[`DetailsCard renders list details 1`] = ` +
+
+
+ + Details Card Header + +
+
+
+ +
+
+`; + +exports[`DetailsCard renders string details 1`] = ` +
+
+
+ + Details Card Header + +
+
+
+ + test details + +
+
+`; + +exports[`DetailsCard renders table details 1`] = ` +
+
+
+ + Details Card Header + +
+
+
+ +
+
+`; + +exports[`DetailsCard renders table details with column defs 1`] = ` +
+
+
+ + Details Card Header + +
+
+
+ +
+
+`; + +exports[`DetailsCard renders with className 1`] = ` +
+
+
+ + Details Card Header + +
+
+
+ +
+
+`; + +exports[`DetailsCard renders with description 1`] = ` +
+
+
+ + Details Card Header + +

+ test description +

+
+
+
+ +
+
+`; diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.css b/packages/jaeger-ui/src/components/common/DetailsCard/index.css similarity index 100% rename from packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.css rename to packages/jaeger-ui/src/components/common/DetailsCard/index.css diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/index.test.js b/packages/jaeger-ui/src/components/common/DetailsCard/index.test.js new file mode 100644 index 0000000000..12a2c1d27d --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/index.test.js @@ -0,0 +1,74 @@ +// 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 DetailsCard from '.'; + +describe('DetailsCard', () => { + const header = 'Details Card Header'; + + it('renders string details', () => { + const details = 'test details'; + expect(shallow()).toMatchSnapshot(); + }); + + it('renders list details', () => { + const details = ['foo', 'bar', 'baz']; + expect(shallow()).toMatchSnapshot(); + }); + + it('renders table details', () => { + const details = [{ value: 'foo' }]; + expect(shallow()).toMatchSnapshot(); + }); + + it('renders table details with column defs', () => { + const columnDefs = ['col']; + const details = [{ [columnDefs[0]]: 'foo' }]; + expect( + shallow() + ).toMatchSnapshot(); + }); + + it('renders with description', () => { + const description = 'test description'; + expect(shallow()).toMatchSnapshot(); + }); + + it('renders with className', () => { + const className = 'test className'; + expect(shallow()).toMatchSnapshot(); + }); + + it('renders as collapsible', () => { + expect(shallow().state('collapsed')).toBe(false); + + const wrapper = shallow(); + expect(wrapper.state('collapsed')).toBe(true); + expect(wrapper).toMatchSnapshot(); + + wrapper.find('button').simulate('click'); + expect(wrapper.state('collapsed')).toBe(false); + + wrapper.find('button').simulate('click'); + expect(wrapper.state('collapsed')).toBe(true); + }); + + it('handles empty details array', () => { + const details = []; + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/index.tsx b/packages/jaeger-ui/src/components/common/DetailsCard/index.tsx new file mode 100644 index 0000000000..74da3cfc45 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/index.tsx @@ -0,0 +1,97 @@ +// 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 cx from 'classnames'; +import MdKeyboardArrowDown from 'react-icons/lib/md/keyboard-arrow-down'; + +import { TColumnDefs, TDetails, TRow } from './types'; +import DetailTable from './DetailTable'; +import DetailList from './DetailList'; + +import './index.css'; + +type TProps = { + className?: string; + collapsible?: boolean; + columnDefs?: TColumnDefs; + description?: string; + details: TDetails; + header: string; +}; + +type TState = { + collapsed: boolean; +}; + +function isList(arr: string[] | TRow[]): arr is string[] { + return typeof arr[0] === 'string'; +} + +export default class DetailsCard extends React.PureComponent { + state: TState; + + constructor(props: TProps) { + super(props); + + this.state = { collapsed: Boolean(props.collapsible) }; + } + + renderDetails() { + const { columnDefs, details } = this.props; + + if (Array.isArray(details)) { + if (details.length === 0) return null; + + if (isList(details)) return ; + return ; + } + + return {details}; + } + + toggleCollapse = () => { + this.setState((prevState: TState) => ({ + collapsed: !prevState.collapsed, + })); + }; + + render() { + const { collapsed } = this.state; + const { className, collapsible, description, header } = this.props; + + return ( +
+
+ {collapsible && ( + + )} +
+ {header} + {description &&

{description}

} +
+
+
+ {this.renderDetails()} +
+
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/types.tsx b/packages/jaeger-ui/src/components/common/DetailsCard/types.tsx new file mode 100644 index 0000000000..1fc6c533c9 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/types.tsx @@ -0,0 +1,34 @@ +// 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 { TExample } from '../ExamplesLink'; + +export type TStyledValue = { + linkTo?: string; + styling?: React.CSSProperties; + value: string | React.ReactElement; +}; + +export type TColumnDef = { + key: string; + label?: string; + preventSort?: boolean; + styling?: React.CSSProperties; +}; + +export type TColumnDefs = (string | TColumnDef)[]; + +export type TRow = Record; + +export type TDetails = string | string[] | TRow[]; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.test.js b/packages/jaeger-ui/src/components/common/ExamplesLink.test.js similarity index 100% rename from packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.test.js rename to packages/jaeger-ui/src/components/common/ExamplesLink.test.js diff --git a/packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.tsx b/packages/jaeger-ui/src/components/common/ExamplesLink.tsx similarity index 92% rename from packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.tsx rename to packages/jaeger-ui/src/components/common/ExamplesLink.tsx index 6d601fea2f..6a1167f399 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.tsx +++ b/packages/jaeger-ui/src/components/common/ExamplesLink.tsx @@ -14,12 +14,15 @@ import * as React from 'react'; -import NewWindowIcon from '../common/NewWindowIcon'; import { getUrl } from '../SearchTracePage/url'; +import NewWindowIcon from './NewWindowIcon'; -import { TExample } from '../../model/path-agnostic-decorations/types'; +export type TExample = { + spanIDs?: string[]; + traceID: string; +}; -export type TProps = { +type TProps = { examples?: TExample[]; includeText?: boolean; }; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/ExamplesLink.test.js.snap b/packages/jaeger-ui/src/components/common/__snapshots__/ExamplesLink.test.js.snap similarity index 100% rename from packages/jaeger-ui/src/components/QualityMetrics/__snapshots__/ExamplesLink.test.js.snap rename to packages/jaeger-ui/src/components/common/__snapshots__/ExamplesLink.test.js.snap diff --git a/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx b/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx index 6b1e20ef8f..6860d62c90 100644 --- a/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx +++ b/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx @@ -14,11 +14,6 @@ import React from 'react'; -export type TExample = { - spanIDs?: string[]; - traceID: string; -}; - export type TPathAgnosticDecorationSchema = { acronym: string; id: string; @@ -36,25 +31,6 @@ export type TPathAgnosticDecorationSchema = { opDetailColumnDefPath?: string; }; -export type TStyledValue = { - linkTo?: string; - styling?: React.CSSProperties; - value: string | React.ReactElement; -}; - -export type TPadColumnDef = { - key: string; - label?: string; - preventSort?: boolean; - styling?: React.CSSProperties; -}; - -export type TPadColumnDefs = (string | TPadColumnDef)[]; - -export type TPadRow = Record; - -export type TPadDetails = string | string[] | TPadRow[]; - export type TPadEntry = number | string; export type TNewData = Record< From 8e3dca241d09d04c8542c8f40b427b96f677db91 Mon Sep 17 00:00:00 2001 From: Everett Ross Date: Thu, 14 May 2020 16:23:29 -0400 Subject: [PATCH 05/12] Clean up types files Signed-off-by: Everett Ross --- packages/jaeger-ui/src/components/QualityMetrics/types.tsx | 1 + .../jaeger-ui/src/model/path-agnostic-decorations/types.tsx | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/jaeger-ui/src/components/QualityMetrics/types.tsx b/packages/jaeger-ui/src/components/QualityMetrics/types.tsx index 202889ec07..c6ac1de0b7 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/types.tsx +++ b/packages/jaeger-ui/src/components/QualityMetrics/types.tsx @@ -17,6 +17,7 @@ import * as React from 'react'; import { TExample } from '../common/ExamplesLink'; import { TColumnDef, TRow } from '../common/DetailsCard/types'; +// eslint-disable-next-line prefer-default export type TQualityMetrics = { traceQualityDocumentationLink: string; bannerText?: diff --git a/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx b/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx index 6860d62c90..96cc12d726 100644 --- a/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx +++ b/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React from 'react'; - export type TPathAgnosticDecorationSchema = { acronym: string; id: string; From fe0a849327b00357b84beb2a6ad43deea62c6194 Mon Sep 17 00:00:00 2001 From: Everett Ross Date: Thu, 14 May 2020 16:41:07 -0400 Subject: [PATCH 06/12] Clean up types files Signed-off-by: Everett Ross --- packages/jaeger-ui/src/components/QualityMetrics/types.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jaeger-ui/src/components/QualityMetrics/types.tsx b/packages/jaeger-ui/src/components/QualityMetrics/types.tsx index c6ac1de0b7..6f2e93719a 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/types.tsx +++ b/packages/jaeger-ui/src/components/QualityMetrics/types.tsx @@ -17,7 +17,7 @@ import * as React from 'react'; import { TExample } from '../common/ExamplesLink'; import { TColumnDef, TRow } from '../common/DetailsCard/types'; -// eslint-disable-next-line prefer-default +// eslint-disable-next-line import/prefer-default-export export type TQualityMetrics = { traceQualityDocumentationLink: string; bannerText?: From 03bc561b6907e1a1670cade4fb0f0c279c95ce47 Mon Sep 17 00:00:00 2001 From: Everett Ross Date: Thu, 14 May 2020 17:25:05 -0400 Subject: [PATCH 07/12] Clean up tests Signed-off-by: Everett Ross --- .../src/components/QualityMetrics/index.test.js | 10 ++++++++-- .../components/common/DetailsCard/DetailTable.test.js | 1 - .../src/components/common/DetailsCard/index.test.js | 10 +++++----- .../src/components/common/ExamplesLink.test.js | 5 ++++- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/jaeger-ui/src/components/QualityMetrics/index.test.js b/packages/jaeger-ui/src/components/QualityMetrics/index.test.js index c6ee95487a..102f9e1911 100644 --- a/packages/jaeger-ui/src/components/QualityMetrics/index.test.js +++ b/packages/jaeger-ui/src/components/QualityMetrics/index.test.js @@ -103,7 +103,7 @@ describe('QualityMetrics', () => { expect(wrapper.state()).toEqual(expectedState); }); - it('clears state and fetches quality metrics if changed', () => { + it('clears state and fetches quality metrics if lookback changed', () => { expect(fetchQualityMetricsSpy).toHaveBeenCalledTimes(1); wrapper.setProps({ lookback: `not-${props.lookback}` }); expect(fetchQualityMetricsSpy).toHaveBeenCalledTimes(2); @@ -199,16 +199,22 @@ describe('QualityMetrics', () => { expect(props.history.push).toHaveBeenLastCalledWith(latestUrl); }); - it('ignores falsy, string, less than one, and fractional lookbacks', () => { + it('ignores falsy lookback', () => { setLookback(null); expect(props.history.push).not.toHaveBeenCalled(); + }); + it('ignores string lookback', () => { setLookback(props.service); expect(props.history.push).not.toHaveBeenCalled(); + }); + it('ignores less than one lookback', () => { setLookback(-1); expect(props.history.push).not.toHaveBeenCalled(); + }); + it('ignores fractional lookback', () => { setLookback(2.5); expect(props.history.push).not.toHaveBeenCalled(); }); 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 96e6ad349b..efb6f08925 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js @@ -153,7 +153,6 @@ describe('DetailTable', () => { expect( makeColumn({ key: stringColumn, - preventSort: true, }) ).toEqual({ diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/index.test.js b/packages/jaeger-ui/src/components/common/DetailsCard/index.test.js index 12a2c1d27d..2ae1f27de4 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/index.test.js +++ b/packages/jaeger-ui/src/components/common/DetailsCard/index.test.js @@ -25,6 +25,11 @@ describe('DetailsCard', () => { expect(shallow()).toMatchSnapshot(); }); + it('handles empty details array', () => { + const details = []; + expect(shallow()).toMatchSnapshot(); + }); + it('renders list details', () => { const details = ['foo', 'bar', 'baz']; expect(shallow()).toMatchSnapshot(); @@ -66,9 +71,4 @@ describe('DetailsCard', () => { wrapper.find('button').simulate('click'); expect(wrapper.state('collapsed')).toBe(true); }); - - it('handles empty details array', () => { - const details = []; - expect(shallow()).toMatchSnapshot(); - }); }); diff --git a/packages/jaeger-ui/src/components/common/ExamplesLink.test.js b/packages/jaeger-ui/src/components/common/ExamplesLink.test.js index eeef6a9caf..d6a0c642c2 100644 --- a/packages/jaeger-ui/src/components/common/ExamplesLink.test.js +++ b/packages/jaeger-ui/src/components/common/ExamplesLink.test.js @@ -34,8 +34,11 @@ describe('ExamplesLink', () => { spanIDs: new Array(i + 1).fill('spanID').map((str, j) => `${str}${i}${j}`), })); - it('renders null when props.examples is absent or empty', () => { + it('renders null when props.examples is absent', () => { expect(shallow().type()).toBe(null); + }); + + it('renders null when props.examples is empty', () => { expect(shallow().type()).toBe(null); }); From 9ae618ebcaf15110d4fd968d070509f0b936138a Mon Sep 17 00:00:00 2001 From: Everett Ross Date: Fri, 15 May 2020 14:41:15 -0400 Subject: [PATCH 08/12] WIP: Add FilterDropdown to DetailsCard _makeColumn TODO: Finish cancel, style, test, verify single select FilteredList Signed-off-by: Everett Ross --- .../Graph/DdgNodeContent/index.tsx | 1 - .../common/DetailsCard/DetailTable.test.js | 8 ++ .../common/DetailsCard/DetailTable.tsx | 106 +++++++++++++++++- .../__snapshots__/DetailTable.test.js.snap | 16 +++ .../common/FilteredList/ListItem.tsx | 28 ++++- .../components/common/FilteredList/index.css | 12 +- .../components/common/FilteredList/index.tsx | 51 ++++++++- 7 files changed, 208 insertions(+), 14 deletions(-) diff --git a/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/index.tsx b/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/index.tsx index 8687ce7e9a..dff987f68f 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/index.tsx +++ b/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/index.tsx @@ -298,7 +298,6 @@ export class UnconnectedDdgNodeContent extends React.PureComponent {}} options={operation} value={null} setValue={this.setOperation} 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..ee9bd188b0 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.test.js @@ -99,6 +99,8 @@ describe('DetailTable', () => { dataIndex: stringColumn, key: stringColumn, title: stringColumn, + filterDropdown: expect.any(Function), + onFilter: expect.any(Function), onCell: expect.any(Function), onHeaderCell: expect.any(Function), render: expect.any(Function), @@ -111,6 +113,8 @@ describe('DetailTable', () => { dataIndex: stringColumn, key: stringColumn, title: stringColumn, + filterDropdown: expect.any(Function), + onFilter: expect.any(Function), onCell: expect.any(Function), onHeaderCell: expect.any(Function), render: expect.any(Function), @@ -129,6 +133,8 @@ describe('DetailTable', () => { dataIndex: stringColumn, key: stringColumn, title: label, + filterDropdown: expect.any(Function), + onFilter: expect.any(Function), onCell: expect.any(Function), onHeaderCell: expect.any(Function), render: expect.any(Function), @@ -159,6 +165,8 @@ describe('DetailTable', () => { dataIndex: stringColumn, key: stringColumn, title: stringColumn, + filterDropdown: expect.any(Function), + onFilter: expect.any(Function), onCell: expect.any(Function), onHeaderCell: expect.any(Function), render: expect.any(Function), diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx index a53b74f941..d0930e8f19 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx @@ -13,13 +13,98 @@ // limitations under the License. import * as React from 'react'; -import { Table } from 'antd'; +import { Button, Table } from 'antd'; import _isEmpty from 'lodash/isEmpty'; import ExamplesLink, { TExample } from '../ExamplesLink'; +import FilteredList from '../FilteredList'; import { TColumnDef, TColumnDefs, TRow, TStyledValue } from './types'; +// TODO Move +export interface ColumnFilterItem { + text: React.ReactNode; + value: string | number | boolean; + children?: ColumnFilterItem[]; +} + +// TODO Move +type TFilterDropdownProps = { + setSelectedKeys: (selectedKeys: React.Key[]) => void; + selectedKeys: React.Key[]; + confirm: () => void; + clearFilters?: () => void; + filters?: ColumnFilterItem[]; +} + +// TODO NOOOOOOOO do not rely on this +let confirmed = true; + +// exported for tests +export const _makeFilterDropdown = (dataIndex: string, rows: TRow[]) => (props: TFilterDropdownProps) => { + const { clearFilters = () => {}, confirm, selectedKeys, setSelectedKeys } = props; + + // if (selectedKeys.length === 0) setSelectedKeys(['agent', 'hor']); + if (!confirmed) { + confirm(); + confirmed = true; + } + console.log(props.filters); + 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); + }); + + const value = new Set(); + selectedKeys.forEach(selected => { + if (typeof selected === 'string') value.add(selected); + }); + + // TODO: Close on scroll? or fix scroll bug + return ( +
+ { + setSelectedKeys([...selectedKeys, ...values]); + // confirmed = false; + }} + multi + options={Array.from(options)} + removeValues={(values: string[]) => { + const remove = new Set(values); + console.log(values, 'remove'); + setSelectedKeys(selectedKeys.filter(key => !remove.has(key))); + // confirmed = false; + }} + setValue={(value: string) => { + setSelectedKeys([value]); + // confirmed = false; + }} + value={value} + /> +
+ +
+ {/* TODO: see if this can make cancel work */ } + + +
+
+
+ ); +}; + + // exported for tests export const _onCell = (dataIndex: string) => (row: TRow) => { const cellData = row[dataIndex]; @@ -31,6 +116,16 @@ export const _onCell = (dataIndex: string) => (row: TRow) => { }; }; +// exported for tests +export const _onFilter = (dataIndex: string) => (value: string, row: TRow) => { + const data = row[dataIndex]; + // console.log(value, data); + 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 +157,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; @@ -84,10 +179,14 @@ export const _makeColumns = ({ defs }: { defs: TColumnDefs }) => dataIndex, key, title, + filterDropdown: _makeFilterDropdown(dataIndex, rows), onCell: _onCell(dataIndex), onHeaderCell: () => ({ + // TODO: see if this can make cancel work + id: dataIndex, style, }), + onFilter: _onFilter(dataIndex), render: _renderCell, sorter: sortable && _sort(dataIndex), }; @@ -138,7 +237,8 @@ export default function DetailTable({
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; }; } @@ -33,20 +37,33 @@ interface IListItemProps extends ListChildComponentProps { export default class ListItem extends React.PureComponent { 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); }; + isSelected = () => { + const { data, index } = this.props; + const { multi, options, selectedValue } = data; + const isSelected = typeof selectedValue === 'string' || !selectedValue + ? options[index] === selectedValue + : selectedValue.has(options[index]); + return isSelected; + } + render() { const { data, index, style: styleOrig } = this.props; // 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, selectedValue } = 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 +74,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/index.css b/packages/jaeger-ui/src/components/common/FilteredList/index.css index 26813b3e13..0515ad836d 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/index.css +++ b/packages/jaeger-ui/src/components/common/FilteredList/index.css @@ -18,10 +18,16 @@ limitations under the License. background: #fafafa; } +.FilteredList--filterCheckbox { + margin-left: 0.5em; + margin-right: 0.5em; +} + .FilteredList--filterWrapper { align-items: center; - display: flex; + background: white; box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.3); + display: flex; position: relative; z-index: 10; } @@ -29,6 +35,8 @@ limitations under the License. .FilteredList--filterIcon { font-size: 1.3em; margin: 0 0.25em 0 0.65em; + /* TODO IFF has checkbox */ + margin-left: 2em; position: absolute; } @@ -37,6 +45,8 @@ limitations under the License. flex: 1; height: auto; padding: 0.5em 0.3em 0.5em 3em; + /* TODO CHECK IF has checkbox, if not necessary just update above */ + padding-left: 2.5em; } .FilteredList--filterInput:focus { diff --git a/packages/jaeger-ui/src/components/common/FilteredList/index.tsx b/packages/jaeger-ui/src/components/common/FilteredList/index.tsx index 55b3a7507a..de5d9b54ec 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 } from 'antd'; import _debounce from 'lodash/debounce'; import matchSorter from 'match-sorter'; import IoIosSearch from 'react-icons/lib/io/ios-search'; @@ -24,10 +25,13 @@ import ListItem from './ListItem'; import './index.css'; 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 = { @@ -64,6 +68,40 @@ export default class FilteredList extends React.PureComponent { return current != null && current.matches(':hover'); } + private getFilteredCheckbox(filtered: string[]) { + const { addValues, removeValues, value } = this.props; + if (!addValues || !removeValues) return null; + + let checkedCount = 0; + let indeterminate = false; + for (let i = 0; i < filtered.length; i++) { + const match = typeof value === 'string' || !value + ? filtered[i] === value + : value.has(filtered[i]); + if (match) checkedCount++; + if (checkedCount && checkedCount <= i) { + indeterminate = true; + break; + } + } + + console.log(checkedCount, indeterminate, filtered.length); + + // TODO: Tooltip + return ( + { + console.log(checked, filtered); + checked ? addValues(filtered) : removeValues(filtered) + }} + indeterminate={indeterminate} + /> + ); + } + private getFilteredOptions = () => { const { options } = this.props; const { filterText } = this.state; @@ -81,7 +119,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,19 +168,24 @@ export default class FilteredList extends React.PureComponent { }; render() { - const { value } = this.props; + const { addValues, multi, 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 (
); diff --git a/packages/jaeger-ui/src/components/common/FilteredList/index.css b/packages/jaeger-ui/src/components/common/FilteredList/index.css index 0515ad836d..128b109b1f 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/index.css +++ b/packages/jaeger-ui/src/components/common/FilteredList/index.css @@ -19,8 +19,11 @@ limitations under the License. } .FilteredList--filterCheckbox { + /* margin-left: 0.5em; margin-right: 0.5em; + */ + margin: 0 0.5em; } .FilteredList--filterWrapper { @@ -32,21 +35,31 @@ limitations under the License. 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; +} + .FilteredList--filterIcon { font-size: 1.3em; margin: 0 0.25em 0 0.65em; - /* TODO IFF has checkbox */ - margin-left: 2em; position: absolute; } +.FilteredList--filterIcon.isMulti { + margin-left: 2em; +} + .FilteredList--filterInput { border: none; flex: 1; height: auto; - padding: 0.5em 0.3em 0.5em 3em; - /* TODO CHECK IF has checkbox, if not necessary just update above */ - padding-left: 2.5em; + padding: 0.5em 0.3em 0.5em 2.5em; } .FilteredList--filterInput:focus { diff --git a/packages/jaeger-ui/src/components/common/FilteredList/index.tsx b/packages/jaeger-ui/src/components/common/FilteredList/index.tsx index de5d9b54ec..cdf3fd3cbd 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/index.tsx +++ b/packages/jaeger-ui/src/components/common/FilteredList/index.tsx @@ -85,8 +85,6 @@ export default class FilteredList extends React.PureComponent { } } - console.log(checkedCount, indeterminate, filtered.length); - // TODO: Tooltip return ( { checked={Boolean(checkedCount) && checkedCount === filtered.length} disabled={!filtered.length} onChange={({ target: { checked } }) => { - console.log(checked, filtered); checked ? addValues(filtered) : removeValues(filtered) }} indeterminate={indeterminate} @@ -184,19 +181,21 @@ export default class FilteredList extends React.PureComponent { }; return (
-
{ + 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(); + }); + + it('initializes this.selected', () => { + expect(wrapper.instance().selected).toBe(props.selectedKeys); + }); + + 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)); + }); + + describe('buttons', () => { + const selectedKeys = [options[0]]; + + it('calls props.clearFilters on Clear Filter', () => { + expect(props.clearFilters).not.toHaveBeenCalled(); + + wrapper + .find(Button) + .first() + .simulate('click'); + expect(props.clearFilters).toHaveBeenCalledTimes(1); + }); + + it('handles missing clearFilters prop', () => { + wrapper.setProps({ clearFilters: undefined }); + expect(() => + wrapper + .find(Button) + .first() + .simulate('click') + ).not.toThrow(); + }); + + it('resets to initial keys on cancel and calls confirm once props reflect cancellation', () => { + wrapper.setProps({ 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(props.selectedKeys); + expect(props.confirm).not.toHaveBeenCalled(); + + wrapper.setProps({ selectedKeys: props.selectedKeys }); + expect(props.setSelectedKeys).toHaveBeenCalledTimes(1); + expect(props.confirm).toHaveBeenCalledTimes(1); + }); + + it('updates this.selected and calls props.confirm on Apply', () => { + wrapper.setProps({ selectedKeys }); + expect(wrapper.instance().selected).not.toBe(selectedKeys); + + wrapper + .find(Button) + .last() + .simulate('click'); + expect(wrapper.instance().selected).toBe(selectedKeys); + expect(props.confirm).toHaveBeenCalledTimes(1); + }); + }); + + 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..efa9441969 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.tsx @@ -0,0 +1,111 @@ +// 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 _isEmpty from 'lodash/isEmpty'; + +import ExamplesLink, { TExample } from '../ExamplesLink'; +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: Array = []; + + constructor(props: TProps) { + super(props); + // TODO CONFIRM THIS STAYS UP TO DATE WHEN CLICKING OUTSIDE DROPDOWN + this.selected = props.selectedKeys; + } + + componentDidUpdate() { + if (this.cancelled) { + this.cancelled = false; + this.props.confirm(); + } + } + + cancel = () => { + this.cancelled = true; + this.props.setSelectedKeys(this.selected); + }; + + confirm = () => { + // TODO CONFIRM THIS IS NECESSARY OR IF CONSTRUCTOR COVERS IT + this.selected = this.props.selectedKeys; + this.props.confirm(); + }; + + render() { + const { clearFilters = () => {}, 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} + /> +
+ + + +
+ + + + + + +
+
+
+ ); + } +} 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 700343f722..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 @@ -19,6 +19,7 @@ exports[`DetailTable render does not duplicate columns 1`] = ` Object { "dataIndex": "col1", "filterDropdown": [Function], + "filterIcon": [Function], "key": "col1", "onCell": [Function], "onFilter": [Function], @@ -30,6 +31,7 @@ exports[`DetailTable render does not duplicate columns 1`] = ` Object { "dataIndex": "col0", "filterDropdown": [Function], + "filterIcon": [Function], "key": "col0", "onCell": [Function], "onFilter": [Function], @@ -74,6 +76,7 @@ exports[`DetailTable render infers all columns 1`] = ` Object { "dataIndex": "col0", "filterDropdown": [Function], + "filterIcon": [Function], "key": "col0", "onCell": [Function], "onFilter": [Function], @@ -85,6 +88,7 @@ exports[`DetailTable render infers all columns 1`] = ` Object { "dataIndex": "col1", "filterDropdown": [Function], + "filterIcon": [Function], "key": "col1", "onCell": [Function], "onFilter": [Function], @@ -125,6 +129,7 @@ exports[`DetailTable render infers missing columns 1`] = ` Object { "dataIndex": "col0", "filterDropdown": [Function], + "filterIcon": [Function], "key": "col0", "onCell": [Function], "onFilter": [Function], @@ -136,6 +141,7 @@ exports[`DetailTable render infers missing columns 1`] = ` Object { "dataIndex": "col1", "filterDropdown": [Function], + "filterIcon": [Function], "key": "col1", "onCell": [Function], "onFilter": [Function], @@ -176,6 +182,7 @@ exports[`DetailTable render renders given rows and columns 1`] = ` Object { "dataIndex": "col1", "filterDropdown": [Function], + "filterIcon": [Function], "key": "col1", "onCell": [Function], "onFilter": [Function], @@ -187,6 +194,7 @@ exports[`DetailTable render renders given rows and columns 1`] = ` Object { "dataIndex": "col0", "filterDropdown": [Function], + "filterIcon": [Function], "key": "col0", "onCell": [Function], "onFilter": [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..f1833e9e79 --- /dev/null +++ b/packages/jaeger-ui/src/components/common/DetailsCard/__snapshots__/DetailTableDropdown.test.js.snap @@ -0,0 +1,105 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DetailTable renders as expected 1`] = ` +
+ +
+ + + +
+ + + + + + +
+
+
+`; 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.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 736d3fae0f..52fbea8acb 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/ListItem.tsx +++ b/packages/jaeger-ui/src/components/common/FilteredList/ListItem.tsx @@ -35,6 +35,16 @@ interface IListItemProps extends ListChildComponentProps { } 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 { addValues, multi, options, removeValues, setValue } = data; @@ -45,21 +55,12 @@ export default class ListItem extends React.PureComponent { } else setValue(value); }; - isSelected = () => { - const { data, index } = this.props; - const { multi, options, selectedValue } = data; - const isSelected = typeof selectedValue === 'string' || !selectedValue - ? options[index] === selectedValue - : selectedValue.has(options[index]); - return isSelected; - } - render() { const { data, index, style: styleOrig } = this.props; // 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, multi, options, selectedValue } = data; + const { focusedIndex, highlightQuery, multi, options } = data; const isSelected = this.isSelected(); const cls = cx('FilteredList--ListItem', { 'is-focused': index === focusedIndex, 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.test.js b/packages/jaeger-ui/src/components/common/FilteredList/index.test.js index 218a4561ea..f5045372e0 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,109 @@ 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('selects all filtered value 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('unselects all filtered value 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('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 cdf3fd3cbd..74ac699c6f 100644 --- a/packages/jaeger-ui/src/components/common/FilteredList/index.tsx +++ b/packages/jaeger-ui/src/components/common/FilteredList/index.tsx @@ -13,7 +13,7 @@ // limitations under the License. import * as React from 'react'; -import { Checkbox } from 'antd'; +import { Checkbox, Tooltip } from 'antd'; import _debounce from 'lodash/debounce'; import matchSorter from 'match-sorter'; import IoIosSearch from 'react-icons/lib/io/ios-search'; @@ -24,6 +24,9 @@ import ListItem from './ListItem'; import './index.css'; +const ITEM_HEIGHT = 35; +const MAX_HEIGHT = 375; + type TProps = { addValues?: (values: string[]) => void; cancel?: () => void; @@ -64,38 +67,45 @@ 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, value } = this.props; + const { addValues, removeValues, options, value } = this.props; if (!addValues || !removeValues) return null; - + let checkedCount = 0; let indeterminate = false; for (let i = 0; i < filtered.length; i++) { - const match = typeof value === 'string' || !value - ? filtered[i] === value - : value.has(filtered[i]); + const match = typeof value === 'string' || !value ? filtered[i] === value : value.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`; // TODO: Tooltip return ( - { - checked ? addValues(filtered) : removeValues(filtered) - }} - indeterminate={indeterminate} - /> + + { + if (newCheckedState) addValues(filtered); + else removeValues(filtered); + }} + indeterminate={indeterminate} + /> + ); } @@ -165,7 +175,7 @@ export default class FilteredList extends React.PureComponent { }; render() { - const { addValues, multi, removeValues, 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); @@ -199,10 +209,10 @@ export default class FilteredList extends React.PureComponent { Date: Tue, 19 May 2020 15:10:00 -0400 Subject: [PATCH 11/12] Handle cancel after click outside list, fix tips Signed-off-by: Everett Ross --- .../common/DetailsCard/DetailTable.tsx | 4 - .../DetailsCard/DetailTableDropdown.css | 26 +++++-- .../DetailsCard/DetailTableDropdown.test.js | 78 ++++++++++++++----- .../DetailsCard/DetailTableDropdown.tsx | 60 ++++++++------ .../DetailTableDropdown.test.js.snap | 32 ++++++-- .../common/FilteredList/index.test.js | 21 +++-- .../components/common/FilteredList/index.tsx | 5 +- 7 files changed, 153 insertions(+), 73 deletions(-) diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx index b346e54b1c..c7687cddca 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTable.tsx @@ -14,14 +14,10 @@ import * as React from 'react'; import { Icon, Table } from 'antd'; -import FaCheck from 'react-icons/lib/fa/check.js'; import FaFilter from 'react-icons/lib/fa/filter.js'; -import FaTrash from 'react-icons/lib/fa/trash.js'; -import TiCancel from 'react-icons/lib/ti/cancel.js'; import _isEmpty from 'lodash/isEmpty'; import ExamplesLink, { TExample } from '../ExamplesLink'; -import FilteredList from '../FilteredList'; import DetailTableDropdown from './DetailTableDropdown'; import { TColumnDef, TColumnDefs, TFilterDropdownProps, TRow, TStyledValue } from './types'; diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.css b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.css index 5d48a87f83..d6366f3382 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.css +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.css @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.DetailDropdown--Footer { +.DetailTableDropdown--Footer { display: flex; background: white; justify-content: space-between; @@ -22,11 +22,11 @@ limitations under the License. padding: 0.3em; } -.DetailDropdown--Footer--CancelConfirm { +.DetailTableDropdown--Footer--CancelConfirm { display: flex; } -.DetailDropdown--Btn { +.DetailTableDropdown--Btn { align-items: center; border-radius: 0; color: #ddd; @@ -34,22 +34,32 @@ limitations under the License. padding: 0.3em 0.7em 0.3em 0.6em; } -.DetailDropdown--Btn ~ .DetailDropdown--Btn { +.DetailTableDropdown--Btn ~ .DetailTableDropdown--Btn { margin-left: 0.3em; } -.DetailDropdown--Btn > *:not(:first-child) { +.DetailTableDropdown--Btn > *:not(:first-child) { margin-left: 0.3em; } -.DetailDropdown--Btn.Apply { +.DetailTableDropdown--Btn.Apply { background: #4c21ce; } -.DetailDropdown--Btn.Cancel { +.DetailTableDropdown--Btn.Cancel { background: #007272; } -.DetailDropdown--Btn.Clear { +.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 index f47ab9ce9c..c32915fce3 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.test.js +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.test.js @@ -37,21 +37,69 @@ describe('DetailTable', () => { wrapper = shallow(); }); - it('initializes this.selected', () => { - expect(wrapper.instance().selected).toBe(props.selectedKeys); - }); + 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('renders as expected', () => { - expect(wrapper).toMatchSnapshot(); + it('handles missing clearFilters prop', () => { + wrapper.setProps({ clearFilters: undefined }); + expect(() => + wrapper + .find(Button) + .first() + .simulate('click') + ).not.toThrow(); + }); }); - 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)); + 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('buttons', () => { + xdescribe('buttons', () => { const selectedKeys = [options[0]]; it('calls props.clearFilters on Clear Filter', () => { @@ -64,16 +112,6 @@ describe('DetailTable', () => { expect(props.clearFilters).toHaveBeenCalledTimes(1); }); - it('handles missing clearFilters prop', () => { - wrapper.setProps({ clearFilters: undefined }); - expect(() => - wrapper - .find(Button) - .first() - .simulate('click') - ).not.toThrow(); - }); - it('resets to initial keys on cancel and calls confirm once props reflect cancellation', () => { wrapper.setProps({ selectedKeys }); expect(props.confirm).not.toHaveBeenCalled(); diff --git a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.tsx b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.tsx index efa9441969..8124d577df 100644 --- a/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.tsx +++ b/packages/jaeger-ui/src/components/common/DetailsCard/DetailTableDropdown.tsx @@ -17,9 +17,7 @@ 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 _isEmpty from 'lodash/isEmpty'; -import ExamplesLink, { TExample } from '../ExamplesLink'; import FilteredList from '../FilteredList'; import { TFilterDropdownProps } from './types'; @@ -32,34 +30,35 @@ type TProps = TFilterDropdownProps & { export default class DetailTableDropdown extends React.PureComponent { cancelled = false; - selected: Array = []; + selected: React.Key[] = []; - constructor(props: TProps) { - super(props); - // TODO CONFIRM THIS STAYS UP TO DATE WHEN CLICKING OUTSIDE DROPDOWN - this.selected = props.selectedKeys; - } + 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; + } + } - componentDidUpdate() { + // Unfortunately antd requires setSelectedKeys and confirm to be called in different cycles. if (this.cancelled) { this.cancelled = false; - this.props.confirm(); + confirm(); } } cancel = () => { + // Unfortunately antd requires setSelectedKeys and confirm to be called in different cycles. this.cancelled = true; this.props.setSelectedKeys(this.selected); }; - confirm = () => { - // TODO CONFIRM THIS IS NECESSARY OR IF CONSTRUCTOR COVERS IT - this.selected = this.props.selectedKeys; - this.props.confirm(); - }; - render() { - const { clearFilters = () => {}, options, selectedKeys, setSelectedKeys } = this.props; + const { clearFilters = () => {}, confirm, options, selectedKeys, setSelectedKeys } = this.props; const value = new Set(); selectedKeys.forEach(selected => { @@ -83,22 +82,33 @@ export default class DetailTableDropdown extends React.PureComponent { }} value={value} /> -
- - -
- - - -
+ } + > + 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 index f1833e9e79..3b8f436632 100644 --- 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 @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`DetailTable renders as expected 1`] = ` +exports[`DetailTable render renders as expected 1`] = `
+ } transitionName="zoom-big-fast" >