Skip to content

Commit

Permalink
DATAP-1621 - adding suggest typeahead and sub-agg search for product …
Browse files Browse the repository at this point in the history
…and issue (#569)

* adding suggest typeahead and sub-agg search for product and issue

update test coverage

* adding styles

* incorporated current Typeahead component (with HighlightingOption)

* Reverted to custom typeahead

* update dist

* Formatting updates, to match expected styling

* update dist

fix cypress test

adjust and make maxResults 5

update dist

* took out stringify call for custom Typeahead component

* Fixed highlighting option for parent item

* cleaned up code

* update dist

* adding parent to options, updating match to return original casing from string

* fixing test

* fix test

* update dist

* restore missing close button

* update dist

---------

Co-authored-by: Chanel Henley <chanel.henley@cfpb.gov>
  • Loading branch information
flacoman91 and Chanel Henley authored Jan 14, 2025
1 parent 3281a15 commit 2c89b14
Show file tree
Hide file tree
Showing 13 changed files with 2,064 additions and 2,210 deletions.
2 changes: 1 addition & 1 deletion cypress/e2e/common/filters.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ describe('Filter Panel', () => {

cy.get('.state .typeahead-selector').should('exist');

cy.get('.state .typeahead-selector li').contains('texas').click();
cy.get('.state .typeahead-selector li').contains('Texas').click();

cy.get('.pill-panel .pill').contains('TX').should('exist');
});
Expand Down
4 changes: 2 additions & 2 deletions dist/ccdb5.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/ccdb5.css.map

Large diffs are not rendered by default.

120 changes: 59 additions & 61 deletions dist/ccdb5.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/ccdb5.js.map

Large diffs are not rendered by default.

150 changes: 150 additions & 0 deletions src/components/Filters/FilterSearch/FilterSearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import '../../Typeahead/Typeahead.scss';
import { useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { Typeahead } from 'react-bootstrap-typeahead';
import { filterAdded } from '../../../actions';
import PropTypes from 'prop-types';
import { useGetAggregations } from '../../../api/hooks/useGetAggregations';
import { SLUG_SEPARATOR } from '../../../constants';
import { normalize } from '../../../utils';
import { ClearButton } from '../../Typeahead/ClearButton/ClearButton';
import HighlightingOption from '../../Typeahead/HighlightingOption/HighlightingOption';
import getIcon from '../../Common/Icon/iconMap';

export const FilterSearch = ({ fieldName }) => {
const ref = useRef();
const dispatch = useDispatch();

const fieldNameNew = fieldName.replace(/_/g, ' ');
const { data } = useGetAggregations();

const aggResults = data[fieldName] || [];
const subaggName = `sub_${fieldName}.raw`.toLowerCase();
const buckets = [];

aggResults.forEach((option) => {
if (buckets.findIndex((item) => item.key === option.key) === -1) {
const parentAgg = { ...option };
parentAgg.isParent = true;
parentAgg.label = option.key;
parentAgg.normalized = normalize(option.key);
parentAgg.position = 0;
parentAgg.top = {
key: option.key,
label: option.key,
normalized: normalize(option.key),
position: 0,
};
buckets.push(parentAgg);
}

if (option[subaggName] && option[subaggName].buckets) {
option[subaggName].buckets.forEach((bucket) => {
const item = {
key: option.key + SLUG_SEPARATOR + bucket.key,
label: bucket.key,
normalized: normalize(bucket.key),
position: 0,
top: {
key: option.key,
label: option.key,
normalized: normalize(option.key),
position: 0,
},
};
buckets.push(item);
});
}
});

const handleClear = () => {
ref.current.clear();
setInputText('');
};

const [inputText, setInputText] = useState('');

const [dropdownOptions, setDropdownOptions] = useState(buckets);

const handleInputChange = (value) => {
setInputText(value);
const rawValue = normalize(value);

if (!rawValue) {
setDropdownOptions(buckets);
} else {
const options = buckets.map((opt) => {
return {
...opt,
position: opt.normalized.indexOf(rawValue),
value,
top: {
...opt.top,
position: opt.top.normalized.indexOf(rawValue),
value,
},
};
});

setDropdownOptions(options);
}
};

const handleSelections = (selected) => {
dispatch(filterAdded(fieldName, selected[0].key));
handleClear();
};

// give the input focus when the component renders the first time
useEffect(() => {
ref.current.focus();
}, [ref]);

return (
<div className="typeahead">
<div className="o-search-input">
<div className="o-search-input__input">
<label
aria-label={'Search ' + fieldName}
className="o-search-input__input-label"
htmlFor={'filter-search' + fieldName}
>
{getIcon('search')}
</label>
<Typeahead
id={'filter-search' + fieldName}
maxResults={5}
minLength={2}
className="typeahead-selector"
filterBy={['key']}
onChange={(selected) => handleSelections(selected)}
onInputChange={(text) => handleInputChange(text)}
placeholder={'Enter name of ' + fieldNameNew}
labelKey="key"
options={dropdownOptions}
ref={ref}
inputProps={{
'aria-label': `${fieldNameNew} Filter Menu Input`,
className: 'a-text-input a-text-input--full',
}}
renderMenuItemChildren={(option) => (
<li className="typeahead-option typeahead-option--multi body-copy">
<HighlightingOption {...option.top} />
{!option.isParent ? (
<div className="typeahead-option__sub">
{option.value ? <HighlightingOption {...option} /> : null}
</div>
) : null}
</li>
)}
/>
{!!inputText && <ClearButton onClear={handleClear} />}
</div>
</div>
</div>
);
};

FilterSearch.propTypes = {
fieldName: PropTypes.string.isRequired,
};
47 changes: 4 additions & 43 deletions src/components/Filters/Issue/Issue.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { sortSelThenCount } from '../../../utils';
import { CollapsibleFilter } from '../CollapsibleFilter/CollapsibleFilter';
import { filtersReplaced } from '../../../reducers/filters/filtersSlice';
import { SLUG_SEPARATOR } from '../../../constants';
import { Typeahead } from '../../Typeahead/Typeahead/Typeahead';
import { selectFiltersIssue } from '../../../reducers/filters/selectors';
import { MoreOrLess } from '../MoreOrLess/MoreOrLess';
import { AggregationBranch } from '../Aggregation/AggregationBranch/AggregationBranch';
import { useGetAggregations } from '../../../api/hooks/useGetAggregations';
import { FilterSearch } from '../FilterSearch/FilterSearch';
import { SLUG_SEPARATOR } from '../../../constants';

// eslint-disable-next-line react/prop-types
export const Issue = () => {
const dispatch = useDispatch();
const [dropdownOptions, setDropdownOptions] = useState([]);
const { data } = useGetAggregations();
const filters = useSelector(selectFiltersIssue);

Expand All @@ -40,33 +36,6 @@ export const Issue = () => {
});
// Make a cloned, sorted version of the aggs
const options = sortSelThenCount(aggsFilters, selections);
// create an array optimized for typeahead
const optionKeys = options.map((opt) => opt.key);

const onInputChange = (value) => {
const num = value.toLowerCase();
if (num === '') {
setDropdownOptions([]);
return;
}
const options = optionKeys.map((opt) => ({
key: opt,
label: opt,
position: opt.toLowerCase().indexOf(num),
value,
}));
setDropdownOptions(options);
};

const onSelection = (items) => {
const replacementFilters = filters
// remove child items
.filter((filter) => filter.indexOf(items[0].key + SLUG_SEPARATOR) === -1)
// add parent item
.concat(items[0].key);
dispatch(filtersReplaced('issue', replacementFilters));
};

const onBucket = (bucket, props) => {
props.subitems = bucket['sub_issue.raw'].buckets;
return props;
Expand All @@ -78,15 +47,7 @@ export const Issue = () => {
desc={desc}
className="aggregation issue"
>
<Typeahead
ariaLabel="Start typing to begin listing issues"
htmlId="issue-typeahead"
placeholder="Enter name of issue"
handleChange={onSelection}
handleInputChange={onInputChange}
hasClearButton={true}
options={dropdownOptions}
/>
<FilterSearch fieldName="issue" />
<MoreOrLess
listComponent={AggregationBranch}
listComponentProps={listComponentProps}
Expand Down
21 changes: 10 additions & 11 deletions src/components/Filters/Issue/Issue.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import fetchMock from 'jest-fetch-mock';
import userEvent from '@testing-library/user-event';
import { merge } from '../../../testUtils/functionHelpers';
import { Issue } from './Issue';
import { listOfIssues } from '../../../testUtils/aggsConstants';
import * as filterActions from '../../../reducers/filters/filtersSlice';
import { filtersState } from '../../../reducers/filters/filtersSlice';
import { aggResponse } from './fixture';
Expand All @@ -32,8 +31,8 @@ describe('Issue', () => {
});

test('Options appear when user types and dispatches filtersReplaced on selection', async () => {
const filtersReplacedSpy = jest
.spyOn(filterActions, 'filtersReplaced')
const filterAddedSpy = jest
.spyOn(filterActions, 'filterAdded')
.mockImplementation(() => jest.fn());
fetchMock.mockResponseOnce(JSON.stringify(aggResponse));
renderComponent();
Expand All @@ -43,15 +42,15 @@ describe('Issue', () => {
const input = screen.getByPlaceholderText('Enter name of issue');
await user.type(input, 'Improper');
const option = await screen.findByRole('option', {
name: /Improper use of your report/,
name: 'Improper use of your report•Reporting company used your report improperly',
});
await user.click(option);

await waitFor(() =>
expect(filtersReplacedSpy).toBeCalledWith('issue', [
'Incorrect information on your report',
listOfIssues[0].key,
]),
expect(filterAddedSpy).toBeCalledWith(
'issue',
'Improper use of your report•Reporting company used your report improperly',
),
);
});

Expand All @@ -78,10 +77,10 @@ describe('Issue', () => {
const input = screen.getByPlaceholderText('Enter name of issue');
await user.type(input, 'Improper');
const option = await screen.findByRole('option', {
name: /Improper use of your report/,
name: 'Improper use of your report•Reporting company used your report improperly',
});
await user.clear(input);

await user.clear(screen.getByPlaceholderText('Enter name of issue'));
expect(input).toHaveValue('');
expect(option).not.toBeInTheDocument();
});
});
2 changes: 2 additions & 0 deletions src/components/Filters/Product/Product.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { selectFiltersProduct } from '../../../reducers/filters/selectors';
import { selectViewTab } from '../../../reducers/view/selectors';
import { useGetAggregations } from '../../../api/hooks/useGetAggregations';
import { FilterSearch } from '../FilterSearch/FilterSearch';

/**
* Helper function generate and sort options
Expand Down Expand Up @@ -98,6 +99,7 @@ export const Product = () => {
desc={desc}
className="aggregation product"
>
<FilterSearch fieldName="product" />
<MoreOrLess
listComponent={AggregationBranch}
listComponentProps={listComponentProps}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ export const HighlightingOption = ({ label, position, value }) => {
}

const start = label.substring(0, position);
const match = label.slice(position, position + value.length);
const end = label.substring(position + value.length);
return (
<span>
{start}
<b>{value}</b>
<b>{match}</b>
{end}
</span>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('HighlightingOption', () => {
<HighlightingOption label="Maryland (MD)" position={0} value="mar" />,
);

expect(container.innerHTML).toBe(`<span><b>mar</b>yland (MD)</span>`);
expect(container.innerHTML).toBe(`<span><b>Mar</b>yland (MD)</span>`);
});

test('Handles position < 0', () => {
Expand Down
17 changes: 16 additions & 1 deletion src/components/Typeahead/Typeahead.scss
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
width: auto;
}

li.typeahead-option {
.typeahead-option {
padding: $gutter-normal;
border-top: solid 1px var(--gray-40);
margin: 0;
Expand All @@ -87,8 +87,23 @@
border-bottom: solid 2px var(--pacific);
background-color: var(--gray-10);
}

&__sub {
font-size: 14px;
margin-left: 10px;
color: var(--pacific);
}

&--multi {
display: flex;
flex-direction: column;
gap: 5px;
color: var(--pacific);
}
}



.o-search-input__input {
&-label {
z-index: 1;
Expand Down
Loading

0 comments on commit 2c89b14

Please sign in to comment.