diff --git a/src/actions/filter.js b/src/actions/filter.js index 605884e2a..1dcc2ea0e 100644 --- a/src/actions/filter.js +++ b/src/actions/filter.js @@ -112,7 +112,7 @@ export function addFilter(filterName, filterValue) { * * @param {string} filterName - which filter was clicked * @param {string} filterValue - the value of the filter that was clicked - * @returns {string} a packaged payload to be used by Redux reducers + * @returns {object} a packaged payload to be used by Redux reducers */ export function removeFilter(filterName, filterValue) { return { diff --git a/src/components/Trends/ExternalTooltip.js b/src/components/Trends/ExternalTooltip.js deleted file mode 100644 index d8c13ade5..000000000 --- a/src/components/Trends/ExternalTooltip.js +++ /dev/null @@ -1,160 +0,0 @@ -/* eslint complexity: ["error", 5] */ -import { CompanyTypeahead } from '../Filters/CompanyTypeahead'; -import { connect } from 'react-redux'; -import { externalTooltipFormatter } from '../../utils/chart'; -import getIcon from '../iconMap'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { removeFilter } from '../../actions/filter'; -import { sanitizeHtmlId } from '../../utils'; - -const WARN_SERIES_BREAK = - 'CFPB updated product and issue options in April 2017 and August 2023.'; - -const LEARN_SERIES_BREAK = - 'https://www.consumerfinance.gov/data-research/consumer-complaints/#past-changes'; - -export class ExternalTooltip extends React.Component { - _spanFormatter(value) { - const { focus, lens, hasCompanyTypeahead, subLens } = this.props; - const elements = []; - const lensToUse = focus ? subLens : lens; - const plurals = { - Product: 'products', - product: 'products', - issue: 'issues', - 'Sub-Issue': 'sub-issues', - sub_product: 'sub-products', - Company: 'companies', - }; - - // Other should never be a selectable focus item - if (value.name === 'Other') { - elements.push( - - All other {plurals[lensToUse]} - , - ); - return elements; - } - - if (focus) { - elements.push( - - {value.name} - , - ); - return elements; - } - - elements.push( - - {value.name} - , - ); - - // add in the close button for Company and there's no focus yet - if (hasCompanyTypeahead) { - elements.push( - , - ); - } - - return elements; - } - - render() { - const { focus, hasTotal, tooltip } = this.props; - if (tooltip && tooltip.values) { - return ( -
- {!!this.props.hasCompanyTypeahead && ( - - )} -

- {this.props.tooltip.heading} - {this.props.tooltip.date} -

-
-
    - {tooltip.values.map((val, key) => ( -
  • - {this._spanFormatter(val)} - {val.value.toLocaleString()} -
  • - ))} -
- - {!!hasTotal && ( -
    -
  • - Total - - {tooltip.total.toLocaleString()} - -
  • -
- )} -
-

- {WARN_SERIES_BREAK}{' '} - - Learn More - -

-
- ); - } - return null; - } -} - -export const mapDispatchToProps = (dispatch) => ({ - remove: (value) => { - dispatch(removeFilter('company', value)); - }, -}); - -export const mapStateToProps = (state) => { - const { focus, lens, subLens } = state.query; - const { chartType, tooltip } = state.trends; - return { - focus: focus ? 'focus' : '', - lens, - subLens, - hasCompanyTypeahead: lens === 'Company' && !focus, - hasTotal: chartType === 'area', - tooltip: externalTooltipFormatter(tooltip), - }; -}; - -// eslint-disable-next-line react-redux/prefer-separate-component-file -export default connect(mapStateToProps, mapDispatchToProps)(ExternalTooltip); - -ExternalTooltip.propTypes = { - focus: PropTypes.string, - lens: PropTypes.string.isRequired, - hasCompanyTypeahead: PropTypes.bool.isRequired, - subLens: PropTypes.string, - remove: PropTypes.func.isRequired, - hasTotal: PropTypes.bool, - tooltip: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]).isRequired, -}; diff --git a/src/components/Trends/ExternalTooltip/ExternalTooltip.js b/src/components/Trends/ExternalTooltip/ExternalTooltip.js new file mode 100644 index 000000000..a01712fc3 --- /dev/null +++ b/src/components/Trends/ExternalTooltip/ExternalTooltip.js @@ -0,0 +1,74 @@ +import { CompanyTypeahead } from '../../Filters/CompanyTypeahead'; +import { useSelector } from 'react-redux'; +import { TooltipRow } from './TooltipRow'; +import { + selectQueryFocus, + selectQueryLens, +} from '../../../reducers/query/selectors'; +import { + selectTrendsChartType, + selectTrendsTooltip, +} from '../../../reducers/trends/selectors'; +import { externalTooltipFormatter } from '../../../utils/chart'; + +const WARN_SERIES_BREAK = + 'CFPB updated product and issue options in April 2017 and August 2023.'; + +const LEARN_SERIES_BREAK = + 'https://www.consumerfinance.gov/data-research/consumer-complaints/#past-changes'; + +export const ExternalTooltip = () => { + const trendsFocus = useSelector(selectQueryFocus); + const focus = trendsFocus ? 'focus' : ''; + const lens = useSelector(selectQueryLens); + const chartType = useSelector(selectTrendsChartType); + const tip = useSelector(selectTrendsTooltip); + const hasCompanyTypeahead = lens === 'Company' && !focus; + const hasTotal = chartType === 'area'; + const tooltip = externalTooltipFormatter(tip); + if (tooltip && tooltip.values) { + return ( +
+ {!!hasCompanyTypeahead && } +

+ {tooltip.heading} + {tooltip.date} +

+
+
    + {tooltip.values.map((val, key) => ( +
  • + + {val.value.toLocaleString()} +
  • + ))} +
+ + {!!hasTotal && ( +
    +
  • + Total + + {tooltip.total.toLocaleString()} + +
  • +
+ )} +
+

+ {WARN_SERIES_BREAK}{' '} + + Learn More + +

+
+ ); + } + return null; +}; diff --git a/src/components/Trends/ExternalTooltip/ExternalTooltip.spec.js b/src/components/Trends/ExternalTooltip/ExternalTooltip.spec.js new file mode 100644 index 000000000..23119482c --- /dev/null +++ b/src/components/Trends/ExternalTooltip/ExternalTooltip.spec.js @@ -0,0 +1,105 @@ +import { ExternalTooltip } from './ExternalTooltip'; +import { merge } from '../../../testUtils/functionHelpers'; +import { defaultQuery } from '../../../reducers/query/query'; +import { defaultTrends } from '../../../reducers/trends/trends'; +import * as filterActions from '../../../actions/filter'; +import { testRender as render, screen } from '../../../testUtils/test-utils'; +import userEvent from '@testing-library/user-event'; + +const renderComponent = (newQueryState, newTrendsState) => { + merge(newTrendsState, defaultQuery); + merge(newTrendsState, defaultTrends); + + const data = { + query: newQueryState, + trends: newTrendsState, + }; + + render(, { + preloadedState: data, + }); +}; + +jest.useRealTimers(); + +describe('component: ExternalTooltip', () => { + const user = userEvent.setup(); + test('empty rendering', () => { + renderComponent({}, {}); + expect(screen.queryByText('foobar')).toBeNull(); + }); + test('rendering', () => { + const query = { + focus: '', + lens: '', + }; + const trends = { + tooltip: { + title: 'Date Range: 1/1/1900 - 1/1/2000', + total: 2900, + values: [ + { colorIndex: 1, name: 'foo', value: 1000 }, + { colorIndex: 2, name: 'bar', value: 1000 }, + { colorIndex: 3, name: 'All other', value: 900 }, + { colorIndex: 4, name: "Eat at Joe's", value: 1000 }, + ], + }, + }; + renderComponent(query, trends); + expect(screen.getByText('foo')).toBeInTheDocument(); + expect(screen.getByText('Date Range:')).toBeInTheDocument(); + expect(screen.getByText('1/1/1900 - 1/1/2000')).toBeInTheDocument(); + expect(screen.getByText('900')).toBeInTheDocument(); + }); + + test('rendering Company typeahead', async () => { + const filterRemovedSpy = jest.spyOn(filterActions, 'removeFilter'); + const query = { focus: '', lens: 'Company' }; + const trends = { + tooltip: { + title: 'Date Range: 1/1/1900 - 1/1/2000', + total: 2900, + values: [ + { colorIndex: 1, name: 'foo', value: 1000 }, + { colorIndex: 2, name: 'bar', value: 1000 }, + { colorIndex: 3, name: 'All other', value: 900 }, + { colorIndex: 4, name: "Eat at Joe's", value: 1000 }, + ], + }, + }; + renderComponent(query, trends); + expect(screen.getByText('foo')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Enter company name'), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Remove foo from comparison set' }), + ).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: 'Remove foo from comparison set' }), + ); + expect(filterRemovedSpy).toHaveBeenCalledWith('company', 'foo'); + }); + + test('hide Company typeahead in company Focus Mode', () => { + const query = { + focus: 'Acme Foobar', + lens: 'Company', + }; + const trends = { + tooltip: { + title: 'Date Range: 1/1/1900 - 1/1/2000', + total: 2900, + values: [ + { colorIndex: 1, name: 'foo', value: 1000 }, + { colorIndex: 2, name: 'bar', value: 1000 }, + { colorIndex: 3, name: 'All other', value: 900 }, + { colorIndex: 4, name: "Eat at Joe's", value: 1000 }, + ], + }, + }; + renderComponent(query, trends); + expect(screen.getByText('foo')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Enter company name')).toBeNull(); + }); +}); diff --git a/src/components/Trends/ExternalTooltip/TooltipRow.js b/src/components/Trends/ExternalTooltip/TooltipRow.js new file mode 100644 index 000000000..a9ebc745c --- /dev/null +++ b/src/components/Trends/ExternalTooltip/TooltipRow.js @@ -0,0 +1,75 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { removeFilter } from '../../../actions/filter'; +import { + selectQueryFocus, + selectQueryLens, + selectQuerySubLens, +} from '../../../reducers/query/selectors'; +import { sanitizeHtmlId } from '../../../utils'; +import getIcon from '../../iconMap'; + +export const TooltipRow = ({ value }) => { + const dispatch = useDispatch(); + const trendsFocus = useSelector(selectQueryFocus); + const focus = trendsFocus ? 'focus' : ''; + const lens = useSelector(selectQueryLens); + const subLens = useSelector(selectQuerySubLens); + const hasCompanyTypeahead = lens === 'Company' && !focus; + const elements = []; + const lensToUse = focus ? subLens : lens; + const plurals = { + Product: 'products', + product: 'products', + issue: 'issues', + 'Sub-Issue': 'sub-issues', + sub_product: 'sub-products', + Company: 'companies', + }; + + // Other should never be a selectable focus item + if (value.name === 'Other') { + elements.push( + + All other {plurals[lensToUse]} + , + ); + return elements; + } + + if (focus) { + elements.push( + + {value.name} + , + ); + return elements; + } + + elements.push( + + {value.name} + , + ); + + // add in the close button for Company and there's no focus yet + if (hasCompanyTypeahead) { + elements.push( + , + ); + } + + return elements; +}; diff --git a/src/components/Trends/TrendsPanel.js b/src/components/Trends/TrendsPanel.js index 156f0cbe9..fee1be08d 100644 --- a/src/components/Trends/TrendsPanel.js +++ b/src/components/Trends/TrendsPanel.js @@ -10,7 +10,7 @@ import { changeDateInterval } from '../../actions/filter'; import { ChartToggles } from '../RefineBar/ChartToggles'; import { CompanyTypeahead } from '../Filters/CompanyTypeahead'; import { connect } from 'react-redux'; -import ExternalTooltip from './ExternalTooltip'; +import { ExternalTooltip } from './ExternalTooltip/ExternalTooltip'; import { FilterPanel } from '../Filters/FilterPanel'; import { FilterPanelToggle } from '../Filters/FilterPanelToggle'; import { FocusHeader } from './FocusHeader'; diff --git a/src/components/__tests__/ExternalTooltip.spec.js b/src/components/__tests__/ExternalTooltip.spec.js deleted file mode 100644 index 61244d454..000000000 --- a/src/components/__tests__/ExternalTooltip.spec.js +++ /dev/null @@ -1,208 +0,0 @@ -import * as trendsUtils from '../../utils/trends'; -import ReduxExternalTooltip, { - ExternalTooltip, - mapDispatchToProps, - mapStateToProps, -} from '../Trends/ExternalTooltip'; -import React from 'react'; -import { Provider } from 'react-redux'; -import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; -import renderer from 'react-test-renderer'; -import { shallow } from 'enzyme'; - -/** - * - * @param {object} query - Query object - * @param {object} tooltip - Tooltip object - * @returns {void} - */ -function setupSnapshot(query, tooltip) { - const middlewares = [thunk]; - const mockStore = configureMockStore(middlewares); - - const store = mockStore({ - query, - trends: { - chartType: 'area', - tooltip, - }, - }); - - return renderer.create( - - - , - ); -} - -xdescribe('initial state', () => { - let query, tooltip; - beforeEach(() => { - query = { - focus: '', - lens: '', - }; - tooltip = { - title: 'Date Range: 1/1/1900 - 1/1/2000', - total: 2900, - values: [ - { colorIndex: 1, name: 'foo', value: 1000 }, - { colorIndex: 2, name: 'bar', value: 1000 }, - { colorIndex: 3, name: 'All other', value: 900 }, - { colorIndex: 4, name: "Eat at Joe's", value: 1000 }, - ], - }; - }); - it('renders without crashing', () => { - const target = setupSnapshot(query, tooltip); - const tree = target.toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('renders nothing without crashing', () => { - const target = setupSnapshot(query, false); - const tree = target.toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('renders Company typehead without crashing', () => { - query.lens = 'Company'; - const target = setupSnapshot(query, tooltip); - const tree = target.toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('renders "Other" without crashing', () => { - tooltip.values.push({ colorIndex: 5, name: 'Other', value: 900 }); - const target = setupSnapshot(query, tooltip); - const tree = target.toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('renders focus without crashing', () => { - query.focus = 'foobar'; - const target = setupSnapshot(query, tooltip); - const tree = target.toJSON(); - expect(tree).toMatchSnapshot(); - }); -}); - -describe('buttons', () => { - let cb = null; - let cbFocus; - let target = null; - - beforeEach(() => { - cb = jest.fn(); - cbFocus = jest.fn(); - target = shallow( - , - ); - }); - - it('remove is called the button is clicked', () => { - const prev = target.find('.tooltip-ul .color__1 .close'); - prev.simulate('click'); - expect(cb).toHaveBeenCalledWith('foo'); - }); -}); - -describe('mapDispatchToProps', () => { - it('provides a way to call remove', () => { - jest.spyOn(trendsUtils, 'scrollToFocus'); - const dispatch = jest.fn(); - mapDispatchToProps(dispatch).remove('Foo'); - expect(dispatch.mock.calls).toEqual([ - [ - { - filterName: 'company', - filterValue: 'Foo', - requery: 'REQUERY_ALWAYS', - type: 'FILTER_REMOVED', - }, - ], - ]); - expect(trendsUtils.scrollToFocus).not.toHaveBeenCalled(); - }); -}); - -describe('mapStateToProps', () => { - let state; - beforeEach(() => { - state = { - query: { - focus: '', - lens: 'Overview', - }, - trends: { - tooltip: { - title: 'Date: 1/1/2015', - total: 100, - values: [], - }, - }, - }; - }); - it('maps state and props', () => { - const actual = mapStateToProps(state); - expect(actual).toEqual({ - focus: '', - lens: 'Overview', - hasCompanyTypeahead: false, - hasTotal: false, - tooltip: { - date: '1/1/2015', - heading: 'Date:', - title: 'Date: 1/1/2015', - total: 100, - values: [], - }, - }); - }); - - it('maps state and props - focus', () => { - state.query.focus = 'something else'; - const actual = mapStateToProps(state); - expect(actual.focus).toEqual('focus'); - }); - - it('handles broken tooltip title', () => { - state.trends.tooltip.title = 'something else'; - const actual = mapStateToProps(state); - expect(actual).toEqual({ - focus: '', - lens: 'Overview', - hasCompanyTypeahead: false, - hasTotal: false, - tooltip: { - date: '', - heading: 'something else:', - title: 'something else', - total: 100, - values: [], - }, - }); - }); -}); diff --git a/src/reducers/trends/selectors.js b/src/reducers/trends/selectors.js index 537343e68..8c74487a6 100644 --- a/src/reducers/trends/selectors.js +++ b/src/reducers/trends/selectors.js @@ -1,4 +1,5 @@ export const selectTrendsChartType = (state) => state.trends.chartType; +export const selectTrendsTooltip = (state) => state.trends.tooltip; export const selectTrendsTotal = (state) => state.trends.total; export const selectTrendsResultsSubProduct = (state) => state.trends.results['sub-product'];