Skip to content

Commit

Permalink
Merge pull request #931 from cfpb/918-help-search-box
Browse files Browse the repository at this point in the history
[Help] New Institution search
  • Loading branch information
meissadia authored Apr 28, 2021
2 parents 8613b73 + 17e8b58 commit 5131346
Show file tree
Hide file tree
Showing 10 changed files with 176 additions and 19 deletions.
2 changes: 1 addition & 1 deletion cypress/integration/data-browser/2018.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const { HOST, ENVIRONMENT } = Cypress.env()
const dbUrl = dbURL.bind(null, HOST)

describe('Data Browser 2018', function () {
if(!isProd(HOST)) it("Only runs in Production")
if(!isProd(HOST) || isBeta(HOST)) it("Only runs in Production")
else {
it('State/Institution/PropertyType', function () {
cy.get({ HOST, ENVIRONMENT }).logEnv()
Expand Down
2 changes: 1 addition & 1 deletion cypress/integration/data-browser/2019.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const { HOST, ENVIRONMENT } = Cypress.env()
const dbUrl = dbURL.bind(null, HOST)

describe('Data Browser 2019', function () {
if(!isProd(HOST)) it("Only runs in Production")
if(!isProd(HOST) || isBeta(HOST)) it("Only runs in Production")
else {
it('State/Institution/PropertyType', function () {
cy.get({ HOST, ENVIRONMENT }).logEnv()
Expand Down
7 changes: 3 additions & 4 deletions cypress/integration/hmda-help/institution.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,7 @@ describe('HMDA Help - Institutions', () => {


// Search for existing Instititution
cy.findByLabelText('LEI').type(INSTITUTION)
cy.findByText('Search Institutions').click()
cy.get('#lei-select').click().type(INSTITUTION + "{enter}")
cy.wait(LOCAL_ACTION_DELAY)
cy.findAllByText('Update')
.eq(1) // First row
Expand Down Expand Up @@ -214,7 +213,7 @@ describe('HMDA Help - Institutions', () => {
const year = years[0].toString()

// Delete
cy.findByLabelText('LEI').type(`${institution}{enter}`)
cy.get('#lei-select').click().type(`${institution}{enter}`)
cy.wait(LOCAL_ACTION_DELAY)
cy.get('table.institutions tbody tr')
.first()
Expand All @@ -232,7 +231,7 @@ describe('HMDA Help - Institutions', () => {
// Create
cy.wait(LOCAL_ACTION_DELAY)
cy.visit(`${HOST}/hmda-help/`)
cy.findByLabelText('LEI').type('MEISSADIATESTBANK001{enter}')
cy.get('#lei-select').click().type('MEISSADIATESTBANK001{enter}')
cy.wait(LOCAL_ACTION_DELAY)
cy.findByText(`Add ${institution} for ${year}`).click()

Expand Down
2 changes: 1 addition & 1 deletion cypress/integration/hmda-help/publication.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('HMDA Help - Publications', () => {
cy.wait(ACTION_DELAY)

// Search for existing Instititution
cy.findByLabelText("LEI").type(INSTITUTION)
cy.get('#lei-select').click().type(INSTITUTION + "{enter}")
cy.findByText('Search Publications').click()
cy.wait(ACTION_DELAY)

Expand Down
2 changes: 1 addition & 1 deletion cypress/integration/tools/RateSpread.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe("Rate Spread Tool", function() {

describe("Rate Spread API", () => {

if(!isCI(ENVIRONMENT) && !isProd(HOST))
if(!isCI(ENVIRONMENT) || !isProd(HOST) || isBeta(HOST))
it(`Does not run on ${HOST}`, () => cy.get({ HOST, TEST_DELAY, ENVIRONMENT }).logEnv())
else {
it("Generates rates from file", () => {
Expand Down
4 changes: 3 additions & 1 deletion src/common/useRemoteJSON.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ export function useRemoteJSON(sourceUrl, options = {}) {
const [isFetching, setIsFetching] = useState(false)
const [error, setError] = useState(null)

const { forceFetch } = options

const shouldFetch =
options.forceFetch ||
forceFetch ||
(process.env.REACT_APP_ENVIRONMENT !== 'CI' && // Not CI
window.location.host.indexOf('localhost') < 0) // Not localhost

Expand Down
128 changes: 128 additions & 0 deletions src/hmda-help/search/FilersSelectBox.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React, { useLayoutEffect, useState } from 'react'
import { createFilter } from 'react-select'
import CreatableSelect from 'react-select/creatable'
import { MenuList } from '../../data-browser/datasets/MenuList'
import { createLEIOption, itemStyleFn, makeItemPlaceholder, sortByLabel } from '../../data-browser/datasets/selectUtils'
import { useRemoteJSON } from '../../common/useRemoteJSON'

let lastTimeout = null

/** Construct the placeholder text for the Select box based on loading status and availability of options */
function itemPlaceholder(loading, hasItems, category, selectedValue) {
if(loading) return 'Loading...'
if (!hasItems || category === 'leis') return `Type to select an Institution`
const placeholder = makeItemPlaceholder(category, [selectedValue])
return placeholder
}

/** Create a map of Filers (Institution Name, LEI) by LEI for efficient access */
function createLeiMap(json) {
if (!json) return {}

return json.institutions.reduce((memo, curr) => {
memo[curr.lei.toUpperCase()] = { ...curr, name: curr.name.toUpperCase() }
return memo
}, {})
}

/** Style adjustments for react-select components */
const styleFn = {
...itemStyleFn,
container: p => ({ ...p, width: '100%' }),
control: p => ({ ...p, borderRadius: '4px' })
}

/** Display feedback regarding user inputs */
const ValidationStatus = ({ items }) => {
if (!items || !items.length) return null
const {type, text} = items[0]
return (
<div id='validation' className={type}>
{text}
</div>
)
}

/** Search box for easier selection of Institutions using the /filers/{year} endpoint to generate options */
export const FilersSearchBox = ({ endpoint, onChange, year, ...rest }) => {
const [selectedValue, setSelectedValue] = useState(null)
const [isInitial, setIsInitial] = useState(true)
const [validationMsgs, setValidationMsgs] = useState([])

const [data, isFetching, error] = useRemoteJSON(
endpoint || `https://ffiec.cfpb.gov/v2/reporting/filers/${year}`,
{ transformReceive: createLeiMap, forceFetch: true }
)

// Enable type-to-search on pageload by focusing the LEI input element
useLayoutEffect(() => {
if (!isFetching && data)
lastTimeout = setTimeout(() => document.querySelector('#lei-select input').focus(), 100)
return () => lastTimeout && clearTimeout(lastTimeout)
}, [data, isFetching])


// Trigger callback with a faux Event containing the Institution info for the selected LEI
const handleSelection = args => {
const item = args
const itemValue = item ? item.value : ''
setIsInitial(false)
setSelectedValue(item)
onChange({
target: { id: 'lei', value: itemValue },
preventDefault: () => null,
})
}

// Generate and sort options, asc by Institution name
const options = data
? Object.keys(data)
.map((d) => createLEIOption(d, data))
.sort(sortByLabel)
: []

// Validation and sanitization of user input
const onInputChange = (text) => {
if (!text) return setValidationMsgs(null)
const cleanUpperCased = text.toUpperCase().replace(/[^\sA-Z0-9+]+/gi, '')
const cleanNoSpace = cleanUpperCased.replace(/\s/gi, '')
const lengthCheck = cleanNoSpace.length
if (lengthCheck < 20 && cleanNoSpace.match(/[0-9]$/)) // Could be an LEI but it's too short
setValidationMsgs([{ type: 'error', text: 'LEI must be 20 characters' }])
else if (lengthCheck === 20) // If you're trying to enter an LEI, this is a correctly formatted LEI
setValidationMsgs([{ type: 'success', text: 'LEI (20 characters)' }])
else if (lengthCheck > 20) // You're probably searching for an Institution name, but if you were trying to enter an LEI...
setValidationMsgs([{ type: 'status', text: `Not an LEI: ${lengthCheck} characters` }])
else
setValidationMsgs(null)
return cleanUpperCased
}


return (
<>
<ValidationStatus items={validationMsgs} />
<CreatableSelect
id='lei-select'
autoFocus
openOnFocus
searchable
simpleValue
controlShouldRenderValue
value={selectedValue}
options={options}
onChange={handleSelection}
onInputChange={onInputChange}
placeholder={itemPlaceholder( isFetching, options.length, 'leis', selectedValue )}
components={{ MenuList }}
filterOption={createFilter({ ignoreAccents: false })}
styles={styleFn}
menuIsOpen={!isFetching && (isInitial || !selectedValue || undefined)}
isDisabled={isFetching}
/>
</>
)
}


export default FilersSearchBox;
2 changes: 1 addition & 1 deletion src/hmda-help/search/ResultsHeading.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const ResultsHeading = props => {
let resultsText = numOfResults === 1 ? 'result' : 'results'
return (
<h2>
{numOfResults} {resultsText} found
{numOfResults} {resultsText} found for this institution
</h2>
)
}
Expand Down
20 changes: 20 additions & 0 deletions src/hmda-help/search/Search.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.SearchForm #lei-select {
max-width: 100%;
text-transform: uppercase;
}

.SearchForm #lei-select input {
margin-top: -0.2em;
}

.SearchForm .error {
color: red;
}

.SearchForm .success {
color: green;
}

.SearchForm #validation {
margin-bottom: 1em;
}
26 changes: 17 additions & 9 deletions src/hmda-help/search/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { nestInstitutionStateForAPI } from '../utils/convert'

import Results from './Results'
import InputSubmit from '../InputSubmit'
import InputText from '../InputText'
import Loading from '../../common/LoadingIcon.jsx'
import InstitutionNotFound from './InstitutionNotFound'
import ServerErrors from './ServerErrors'
Expand All @@ -15,6 +14,8 @@ import PublicationTable from '../publications/PublicationTable'
import { getFilingYears, getFilingPeriods } from '../../common/constants/configHelpers'
import { SubmissionStatus } from './SubmissionStatus'
import * as AccessToken from '../../common/api/AccessToken'
import { FilersSearchBox } from './FilersSelectBox'
import './Search.css'

function onlyUnique(value, index, self) {
return self.indexOf(value) === index;
Expand Down Expand Up @@ -89,6 +90,7 @@ class Form extends Component {

handleSubmit(event) {
event.preventDefault()
if (!this.state.lei) return

this.setState({
fetching: true,
Expand All @@ -113,12 +115,19 @@ class Form extends Component {
this.handleSubmit(event)
}

isBtnDisabled = (type) => this.state.searchType === type && this.state.fetching
isBtnDisabled = (type) => !this.state.lei || (this.state.lei.length !== 20) || (this.state.searchType === type && this.state.fetching)

onInputTextChange = event => {
let {id, value} = event.target
if(id === 'lei') value = value.toUpperCase()
this.setState({ [id]: value })

if (id === 'lei') // Sanitize LEI input
value = value.toUpperCase().replace(/[\s]/g, '')

// Automatically retrieve Institution listings after storing selection
this.setState({ [id]: value }, () => {
if (this.state.lei.length !== 20) return
this.handleSubmitButton(event, 'search')
})
}

render() {
Expand All @@ -134,6 +143,7 @@ class Form extends Component {
const { config } = this.props

let leis = institutions && institutions.map(i => i.lei).filter(onlyUnique)
const year = getFilingYears(this.props.config).filter(x => !x.includes('Q'))[1]

return (
<React.Fragment>
Expand All @@ -146,14 +156,12 @@ class Form extends Component {
{searchInputs.map((textInput) => {
delete textInput.validation
return (
<InputText
<FilersSearchBox
key={textInput.id}
ref={(input) => {
this[textInput.id] = input
}}
{...textInput}
onChange={this.onInputTextChange}
value={this.state[textInput.id]}
year={year}
{...textInput}
/>
)
})}
Expand Down

0 comments on commit 5131346

Please sign in to comment.