Skip to content

Commit

Permalink
Updates to AddClaimantModal (#14202)
Browse files Browse the repository at this point in the history
Connects #14153

### Description
This adds functionality to `AddClaimantModal` — implementing debounce on searchable dropdown and async fetching from backend API.

### Acceptance Criteria
- [ ] Code compiles correctly
- [ ] Adds debounce functionality
- [ ] Pulls select options via async fetch from backend
- [ ] Saves claimant `participant_id`
- [ ] Displays added claimant in radio list
- [ ] Supports removing claimant from list
- [ ] Fleshes out feature test
  • Loading branch information
jcq authored May 29, 2020
1 parent 55adb2e commit 9b70c85
Show file tree
Hide file tree
Showing 11 changed files with 641 additions and 127 deletions.
120 changes: 67 additions & 53 deletions client/app/components/SearchableDropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const NO_RESULTS_TEXT = 'Not an option';
const DEFAULT_PLACEHOLDER = 'Select option';

class SearchableDropdown extends React.Component {

constructor(props) {
super(props);

Expand Down Expand Up @@ -43,17 +42,19 @@ class SearchableDropdown extends React.Component {
this.setState({ value: newValue });
}

if (this.state.value &&
value &&
Array.isArray(value) &&
Array.isArray(this.state.value) &&
value.length < this.state.value.length) {
if (
this.state.value &&
value &&
Array.isArray(value) &&
Array.isArray(this.state.value) &&
value.length < this.state.value.length
) {
deletedValue = _.differenceWith(this.state.value, value, _.isEqual);
}
if (this.props.onChange) {
this.props.onChange(newValue, deletedValue);
}
}
};

// Override the default keys to create a new tag (allows creating options that contain a comma)
shouldKeyDownEventCreateNewOption = ({ keyCode }) => {
Expand Down Expand Up @@ -81,6 +82,9 @@ class SearchableDropdown extends React.Component {
const {
async,
options,
filterOption,
filterOptions,
loading,
placeholder,
errorMessage,
label,
Expand Down Expand Up @@ -114,12 +118,10 @@ class SearchableDropdown extends React.Component {
* custom tag entered already exits.
* promptTextCreator: this is a function called to show the text when a tag
* entered doesn't exist in the current list of options.
*/
*/
if (creatable) {
addCreatableOptions = {
noResultsText: _.get(
creatableOptions, 'tagAlreadyExistsMsg', TAG_ALREADY_EXISTS_MSG
),
noResultsText: _.get(creatableOptions, 'tagAlreadyExistsMsg', TAG_ALREADY_EXISTS_MSG),

// eslint-disable-next-line no-shadow
newOptionCreator: ({ label, labelKey, valueKey }) => ({
Expand All @@ -144,44 +146,46 @@ class SearchableDropdown extends React.Component {
addCreatableOptions.noResultsText = '';
}

const labelContents =
const labelContents = (
<span>
{label || name}
{required && <span className="cf-required">Required</span>}
</span>;

return <div className={dropdownClasses} {...dropdownStyling}>
<label className={labelClasses} htmlFor={name}>
{
strongLabel ?
<strong>{labelContents}</strong> :
labelContents
}
</label>
<div className={errorMessage ? 'usa-input-error' : ''}>
{errorMessage && <span className="usa-input-error-message">{errorMessage}</span>}
<SelectComponent
inputProps={{
id: name,
autoComplete: 'off'
}}
options={options}
loadOptions={async}
onChange={this.onChange}
value={this.state.value}
placeholder={placeholder === null ? DEFAULT_PLACEHOLDER : placeholder}
clearable={false}
noResultsText={noResultsText ? noResultsText : NO_RESULTS_TEXT}
searchable={searchable}
disabled={readOnly}
multi={multi}
cache={false}
onBlurResetsInput={false}
shouldKeyDownEventCreateNewOption={this.shouldKeyDownEventCreateNewOption}
{...addCreatableOptions}
/>
</span>
);

return (
<div className={dropdownClasses} {...dropdownStyling}>
<label className={labelClasses} htmlFor={name}>
{strongLabel ? <strong>{labelContents}</strong> : labelContents}
</label>
<div className={errorMessage ? 'usa-input-error' : ''}>
{errorMessage && <span className="usa-input-error-message">{errorMessage}</span>}
<SelectComponent
inputProps={{
id: name,
autoComplete: 'off'
}}
options={options}
filterOption={filterOption}
filterOptions={filterOptions}
loadOptions={async}
isLoading={loading}
onChange={this.onChange}
value={this.state.value}
placeholder={placeholder === null ? DEFAULT_PLACEHOLDER : placeholder}
clearable={false}
noResultsText={noResultsText ? noResultsText : NO_RESULTS_TEXT}
searchable={searchable}
disabled={readOnly}
multi={multi}
cache={false}
onBlurResetsInput={false}
shouldKeyDownEventCreateNewOption={this.shouldKeyDownEventCreateNewOption}
{...addCreatableOptions}
/>
</div>
</div>
</div>;
);
}
}

Expand All @@ -194,21 +198,23 @@ SearchableDropdown.propTypes = {
}),
dropdownStyling: PropTypes.object,
errorMessage: PropTypes.string,
filterOption: PropTypes.func,
filterOptions: PropTypes.func,
label: PropTypes.string,
strongLabel: PropTypes.bool,
hideLabel: PropTypes.bool,
loading: PropTypes.bool,
multi: PropTypes.bool,
name: PropTypes.string.isRequired,
noResultsText: PropTypes.string,
onChange: PropTypes.func,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.any,
label: PropTypes.string
})),
placeholder: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object
]),
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.any,
label: PropTypes.string
})
),
placeholder: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
readOnly: PropTypes.bool,
required: PropTypes.bool,
searchable: PropTypes.bool,
Expand All @@ -217,4 +223,12 @@ SearchableDropdown.propTypes = {
value: PropTypes.object
};

/* eslint-disable no-undefined */
SearchableDropdown.defaultProps = {
loading: false,
filterOption: undefined,
filterOptions: undefined
};
/* eslint-enable no-undefined */

export default SearchableDropdown;
82 changes: 60 additions & 22 deletions client/app/intake/components/AddClaimantModal.jsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,50 @@
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo } from 'react';
import PropTypes from 'prop-types';
import { debounce } from 'lodash';
import Modal from '../../components/Modal';
import { ADD_CLAIMANT_MODAL_TITLE, ADD_CLAIMANT_MODAL_DESCRIPTION } from '../../../COPY';
import ReactMarkdown from 'react-markdown';
import SearchableDropdown from '../../components/SearchableDropdown';
import ApiUtil from '../../util/ApiUtil';

const sampleData = [
{ id: 1, fullName: 'Attorney 1', cssId: 'CSS_ID_1', participantId: 1 },
{ id: 2, fullName: 'Attorney 2', cssId: 'CSS_ID_2', participantId: 2 },
{ id: 3, fullName: 'Attorney 3', cssId: 'CSS_ID_3', participantId: 3 }
];
const relationshipOpts = [{ label: 'Attorney (previously or currently)', value: 'attorney' }];

export const AddClaimantModal = ({ onCancel, onSubmit }) => {
const fetchAttorneys = async (search = '') => {
const res = await ApiUtil.get('/intake/attorneys', { query: { query: search } });

return res?.body;
};
const debouncedFetch = (fetchFn) => debounce(fetchFn, 250, { leading: true, trailing: false });
const getClaimantOpts = async (search = '', asyncFn) => {
// Enforce minimum search length (we'll simply return empty array rather than throw error)
const options =
search.length < 3 ?
[] :
(await debouncedFetch(asyncFn)(search)).map((item) => ({
label: item.name,
value: item.participant_id
}));

return { options };
};
// We'll show all items returned from the backend instead of using default substring matching
const filterOptions = (options) => options;

export const AddClaimantModal = ({ onCancel, onSubmit, onSearch = fetchAttorneys }) => {
const [claimant, setClaimant] = useState(null);
const [searchResults] = useState(sampleData);
const [dropdownOpts, setDropdownOpts] = useState([]);
const [loading, setLoading] = useState(false);
const [relationship, setRelationship] = useState(relationshipOpts[0]);
const isInvalid = useMemo(() => !claimant, [claimant]);
const handleChange = (value) => setClaimant(value);
const handleChangeRelationship = (value) => setRelationship(value);
const handleChangeClaimant = (value) => setClaimant(value);
const asyncFn = async (search) => {
setLoading(true);
const results = await getClaimantOpts(search, onSearch);

setLoading(false);

useEffect(() => {
setDropdownOpts(
searchResults.map((item) => ({
label: item.fullName,
value: item.participantId
}))
);
}, [searchResults]);
return results;
};

const buttons = [
{
Expand All @@ -36,22 +55,41 @@ export const AddClaimantModal = ({ onCancel, onSubmit }) => {
{
classNames: ['usa-button', 'usa-button-primary'],
name: 'Add this claimant',
onClick: () => onSubmit({ participantId: claimant.value }),
onClick: () => onSubmit({ name: claimant.label, participantId: claimant.value }),
disabled: isInvalid
}
];

return (
<Modal title={ADD_CLAIMANT_MODAL_TITLE} buttons={buttons} closeHandler={onCancel}>
<Modal title={ADD_CLAIMANT_MODAL_TITLE} buttons={buttons} closeHandler={onCancel} id="add_claimant_modal">
<div>
<ReactMarkdown source={ADD_CLAIMANT_MODAL_DESCRIPTION} />
</div>
<SearchableDropdown label="Claimant's name" options={dropdownOpts} onChange={handleChange} value={claimant} />
<SearchableDropdown
name="relationship"
label="Claimant's relationship to the veteran"
onChange={handleChangeRelationship}
value={relationship}
options={relationshipOpts}
debounce={250}
/>
<SearchableDropdown
name="claimant"
label="Claimant's name"
onChange={handleChangeClaimant}
value={claimant}
filterOptions={filterOptions}
async={asyncFn}
loading={loading}
options={[]}
debounce={250}
/>
</Modal>
);
};

AddClaimantModal.propTypes = {
onCancel: PropTypes.func,
onSubmit: PropTypes.func
onSubmit: PropTypes.func,
onSearch: PropTypes.func
};
20 changes: 19 additions & 1 deletion client/app/intake/components/AddClaimantModal.stories.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import faker from 'faker';

import { action } from '@storybook/addon-actions';
import { AddClaimantModal } from './AddClaimantModal';
Expand All @@ -9,8 +10,25 @@ export default {
decorators: []
};

// Set up sample data & async fn for performing fuzzy search
// Actual implementation performs fuzzy search via backend ruby gem
const totalRecords = 500;
const data = Array.from({ length: totalRecords }, () => ({
name: faker.name.findName(),
participant_id: faker.random.number({ min: 600000000, max: 600000000 + totalRecords })
}));
const performQuery = async (search = '') => {
const regex = RegExp(search, 'i');

return data.filter((item) => regex.test(item.name));
};

export const standard = () => (
<AddClaimantModal onCancel={action('cancel', 'standard')} onSubmit={action('submit', 'standard')} />
<AddClaimantModal
onCancel={action('cancel', 'standard')}
onSubmit={action('submit', 'standard')}
onSearch={performQuery}
/>
);

standard.story = {
Expand Down
Loading

0 comments on commit 9b70c85

Please sign in to comment.