Add searchable dropdown

Add searchable dropdown field to make the UI
complaint with the suggestions.

VAN-498
This commit is contained in:
uzairr
2021-06-09 19:08:19 +05:00
committed by Waheed Ahmed
parent 1dba96b865
commit 5358538d22
7 changed files with 238 additions and 28 deletions

5
package-lock.json generated
View File

@@ -20967,6 +20967,11 @@
"@emotion/core": "^10.0.22"
}
},
"react-onclickoutside": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.11.2.tgz",
"integrity": "sha512-640486eSwU/t5iD6yeTlefma8dI3bxPXD93hM9JGKyYITAd0P1JFkkcDeyHZRqNpY/fv1YW0Fad9BXr44OY8wQ=="
},
"react-overlays": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-4.1.1.tgz",

View File

@@ -58,6 +58,7 @@
"react-dom": "16.14.0",
"react-helmet": "6.1.0",
"react-loading-skeleton": "2.2.0",
"react-onclickoutside": "^6.11.2",
"react-redux": "7.2.3",
"react-responsive": "8.2.0",
"react-router": "5.2.0",

View File

@@ -425,13 +425,11 @@ select.form-control {
.medium-screen-svg {
fill: $primary;
overflow: inherit;
position: absolute;
}
.large-screen-svg {
fill: $primary;
overflow: hidden;
position: absolute;
}
.small-screen-header {
@@ -518,7 +516,7 @@ select.form-control {
}
.large-heading {
margin-left: 7px;
margin-left: 8px;
color: $white;
max-width: 24rem;
line-height: 78px;
@@ -541,9 +539,9 @@ select.form-control {
}
.logo {
width: 4.44rem;
margin-top: 1.5rem;
margin-left: 1.5rem;
width: 4.5rem;
padding-top: 2rem;
padding-left: 25px;
}
.username-suggestion {
@@ -612,3 +610,33 @@ select.form-control {
color: $gray-700;
text-decoration: none;
}
.dropdown-item:active {
background-color: #F2F0EF;
}
.dropdown-container {
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.15), 0px 2px 8px rgba(0, 0, 0, 0.15);
border-radius: 4px;
max-height: 200px;
font-size: 1rem;
font-weight: normal;
line-height: 1.25rem;
overflow-y: scroll;
position: absolute;
background-color: #fff;
width: 464px;
}
@media (max-width: 464px) {
.dropdown-container {
width: auto;
left: 0;
right: 0;
position: relative;
}
}
.-mt-4 {
margin-top: -1.5rem;
}

View File

@@ -13,6 +13,9 @@ const FormGroup = (props) => {
setHasFocus(true);
if (props.handleFocus) { props.handleFocus(e); }
};
const handleClick = (e) => {
if (props.handleClick) { props.handleClick(e); }
};
const handleOnBlur = (e) => {
setHasFocus(false);
if (props.handleBlur) { props.handleBlur(e); }
@@ -24,10 +27,12 @@ const FormGroup = (props) => {
as={props.as}
type={props.type}
autoComplete={props.autoComplete}
name={props.name}
value={props.value}
onFocus={handleFocus}
onBlur={handleOnBlur}
onClick={handleClick}
onChange={props.handleChange}
controlClassName={props.borderClass}
@@ -66,9 +71,11 @@ FormGroup.defaultProps = {
borderClass: '',
suggestedTopLevelDomain: '',
suggestedServiceLevelDomain: '',
autoComplete: null,
handleBlur: null,
handleChange: () => {},
handleFocus: null,
handleClick: null,
helpText: [],
options: null,
trailingElement: null,
@@ -82,10 +89,12 @@ FormGroup.propTypes = {
borderClass: PropTypes.string,
suggestedTopLevelDomain: PropTypes.string,
suggestedServiceLevelDomain: PropTypes.string,
autoComplete: PropTypes.string,
floatingLabel: PropTypes.string.isRequired,
handleBlur: PropTypes.func,
handleChange: PropTypes.func,
handleFocus: PropTypes.func,
handleClick: PropTypes.func,
helpText: PropTypes.arrayOf(PropTypes.string),
name: PropTypes.string.isRequired,
options: PropTypes.func,

View File

@@ -0,0 +1,172 @@
import React from 'react';
import { Icon } from '@edx/paragon';
import { ExpandMore, ExpandLess } from '@edx/paragon/icons';
import onClickOutside from 'react-onclickoutside';
import PropTypes from 'prop-types';
import { FormGroup } from '../common-components';
import { FORM_SUBMISSION_ERROR } from './data/constants';
class CountryDropdown extends React.Component {
constructor(props) {
super(props);
this.state = {
displayValue: '',
icon: ExpandMore,
errorMessage: '',
showFieldError: true,
};
this.handleFocus = this.handleFocus.bind(this);
this.handleOnBlur = this.handleOnBlur.bind(this);
}
static getDerivedStateFromProps(props, state) {
if (props.errorCode === FORM_SUBMISSION_ERROR && state.showFieldError) {
return { errorMessage: props.errorMessage };
}
return null;
}
getItems(strToFind = '') {
let { options } = this.props;
if (strToFind.length > 0) {
options = options.filter((option) => (option.name.toLowerCase().includes(strToFind.toLowerCase())));
}
return options.map((opt) => {
const value = opt[this.props.valueKey];
let displayValue = opt[this.props.displayValueKey];
if (displayValue.length > 30) {
displayValue = displayValue.substring(0, 30).concat('...');
}
return (
<button type="button" className="dropdown-item" value={value} key={value} onClick={(e) => { this.handleItemClick(e); }}>
{displayValue}
</button>
);
});
}
setValue(value) {
if (this.props.value === value) {
return;
}
if (this.props.handleChange) {
this.props.handleChange(value);
}
const opt = this.props.options.find((o) => o[this.props.valueKey] === value);
if (opt && opt[this.props.displayValueKey] !== this.state.displayValue) {
this.setState({ displayValue: opt[this.props.displayValueKey] });
}
}
setDisplayValue(value) {
const normalized = value.toLowerCase();
const opt = this.props.options.find((o) => o[this.props.displayValueKey].toLowerCase() === normalized);
if (opt) {
this.setValue(opt[this.props.valueKey]);
this.setState({ displayValue: opt[this.props.displayValueKey] });
} else {
this.setValue(null);
this.setState({ displayValue: value });
}
}
handleClick = () => {
if (!this.props.value) {
const dropDownItems = this.getItems();
this.setState({
dropDownItems, icon: ExpandLess, errorMessage: '', showFieldError: false,
});
}
}
handleOnChange = (e) => {
const findstr = e.target.value;
if (findstr.length > 0) {
const filteredItems = this.getItems(findstr);
this.setState({ dropDownItems: filteredItems, icon: ExpandLess, errorMessage: '' });
} else {
this.setState({ dropDownItems: '', icon: ExpandMore, errorMessage: this.props.errorMessage });
}
this.setDisplayValue(e.target.value);
}
handleClickOutside = () => {
if (this.state.dropDownItems?.length > 0) {
const msg = this.state.displayValue === '' ? this.props.errorMessage : '';
this.setState(() => ({
icon: ExpandMore,
dropDownItems: '',
errorMessage: msg,
}));
}
}
handleFocus(e) {
if (this.props.handleFocus) { this.props.handleFocus(e); }
}
handleOnBlur(e) {
if (this.props.handleBlur) { this.props.handleBlur(e); }
}
handleItemClick(e) {
this.setValue(e.target.value);
this.setState({ dropDownItems: '', icon: ExpandMore });
}
render() {
return (
<div>
<FormGroup
as="input"
name={this.props.name}
autoComplete="off"
floatingLabel={this.props.floatingLabel}
trailingElement={<Icon src={this.state.icon} />}
handleChange={this.handleOnChange}
handleClick={this.handleClick}
handleBlur={this.handleOnBlur}
handleFocus={this.handleOnFocus}
value={this.state.displayValue}
errorMessage={this.state.errorMessage}
/>
<div className="dropdown-container -mt-4">
{ this.state.dropDownItems?.length > 0 ? this.state.dropDownItems : null }
</div>
</div>
);
}
}
CountryDropdown.defaultProps = {
options: null,
floatingLabel: null,
handleFocus: null,
handleChange: null,
handleBlur: null,
value: null,
errorMessage: null,
};
CountryDropdown.propTypes = {
options: PropTypes.arrayOf(PropTypes.object),
floatingLabel: PropTypes.string,
valueKey: PropTypes.string.isRequired,
displayValueKey: PropTypes.string.isRequired,
handleFocus: PropTypes.func,
handleChange: PropTypes.func,
handleBlur: PropTypes.func,
value: PropTypes.string,
errorMessage: PropTypes.string,
errorCode: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
};
export default onClickOutside(CountryDropdown);

View File

@@ -13,9 +13,8 @@ import {
} from '@edx/frontend-platform/i18n';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import {
Form, Hyperlink, Icon, StatefulButton,
Form, Hyperlink, StatefulButton,
} from '@edx/paragon';
import { ExpandMore } from '@edx/paragon/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { closest } from 'fastest-levenshtein';
@@ -45,6 +44,7 @@ import {
import {
getTpaProvider, getTpaHint, getAllPossibleQueryParam, setSurveyCookie,
} from '../data/utils';
import CountryDropdown from './CountryDropdown';
class RegistrationPage extends React.Component {
constructor(props, context) {
@@ -131,12 +131,6 @@ class RegistrationPage extends React.Component {
}
};
getCountryOptions = () => [
{ code: '', name: this.props.intl.formatMessage(messages['registration.country.label']) },
].concat(getCountryList(getLocale())).map(({ code, name }) => (
<option className="data-hj-suppress" key={code} value={code}>{name}</option>
));
getOptionalFields() {
return (
<OptionalFields
@@ -498,19 +492,20 @@ class RegistrationPage extends React.Component {
floatingLabel={intl.formatMessage(messages['registration.password.label'])}
/>
)}
<FormGroup
as="select"
<CountryDropdown
name="country"
floatingLabel={intl.formatMessage(messages['registration.country.label'])}
options={getCountryList(getLocale())}
valueKey="code"
displayValueKey="name"
value={this.state.country}
handleBlur={this.handleOnBlur}
handleChange={this.handleOnChange}
handleFocus={this.handleOnFocus}
errorMessage={this.state.errors.country}
floatingLabel={intl.formatMessage(messages['registration.country.label'])}
trailingElement={<Icon src={ExpandMore} />}
options={this.getCountryOptions}
errorMessage={intl.formatMessage(messages['empty.country.field.error'])}
handleChange={(value) => this.setState({ country: value })}
errorCode={this.state.errorCode}
/>
<div id="honor-code" className="small">
<div id="honor-code" className="small mt-4">
<FormattedMessage
id="register.page.terms.of.service.and.honor.code"
defaultMessage="By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each

View File

@@ -89,7 +89,7 @@ describe('RegistrationPage', () => {
registerPage.find('input#name').simulate('change', { target: { value: payload.name, name: 'name' } });
registerPage.find('input#username').simulate('change', { target: { value: payload.username, name: 'username' } });
registerPage.find('input#email').simulate('change', { target: { value: payload.email, name: 'email' } });
registerPage.find('select#country').simulate('change', { target: { value: payload.country, name: 'country' } });
registerPage.find('input#country').simulate('change', { target: { value: payload.country } });
if (!isThirdPartyAuth) {
registerPage.find('input#password').simulate('change', { target: { value: payload.password, name: 'password' } });
@@ -138,7 +138,7 @@ describe('RegistrationPage', () => {
populateRequiredFields(registerPage, payload);
registerPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser(payload));
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
});
it('should submit form without password field when current provider is present', () => {
@@ -169,7 +169,7 @@ describe('RegistrationPage', () => {
populateRequiredFields(registerPage, formPayload, true);
registerPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser(formPayload));
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' }));
});
it('should not dispatch registerNewUser on empty form Submission', () => {
@@ -219,7 +219,7 @@ describe('RegistrationPage', () => {
registrationPage.find('input#name').simulate('blur', { target: { value: '', name: 'name' } });
registrationPage.find('input#email').simulate('blur', { target: { value: '', name: 'email' } });
registrationPage.find('input#password').simulate('blur', { target: { value: '', name: 'password' } });
registrationPage.find('select#country').simulate('blur', { target: { value: '', name: 'country' } });
registrationPage.find('input#country').simulate('blur', { target: { value: '', name: 'country' } });
expect(registrationPage.find('RegistrationPage').state('errors')).toEqual(emptyFieldValidation);
});
@@ -315,7 +315,7 @@ describe('RegistrationPage', () => {
expect(registrationPage.find('div[feedback-for="password"]').text()).toContain(emptyFieldValidation.password);
registrationPage.find('input#password').simulate('focus');
expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(emptyFieldValidation.country);
registrationPage.find('select#country').simulate('blur', { target: { value: 'US', name: 'country' } });
registrationPage.find('input#country').simulate('blur', { target: { value: 'US', name: 'country' } });
expect(registrationPage.find('RegistrationPage').state('errors')).toEqual(errors);
});
@@ -784,7 +784,7 @@ describe('RegistrationPage', () => {
registerPage.find('textarea#goals').simulate('change', { target: { value: 'edX goals', name: 'goals' } });
registerPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser(payload));
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
});
});
});