feat: add typeahead component (#375)

This commit is contained in:
Kristin Aoki
2023-08-16 18:18:06 -04:00
committed by GitHub
parent e9c0f6cc82
commit 9438a5b89a
7 changed files with 605 additions and 2 deletions

16
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"moment": "^2.29.4",
"moment-shortformat": "^2.1.0",
"react-dropzone": "^14.2.3",
"react-onclickoutside": "^6.13.0",
"react-redux": "^7.2.8",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.2",
@@ -18826,6 +18827,19 @@
"react": ">=16.8.0"
}
},
"node_modules/react-onclickoutside": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.13.0.tgz",
"integrity": "sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A==",
"funding": {
"type": "individual",
"url": "https://github.com/Pomax/react-onclickoutside/blob/master/FUNDING.md"
},
"peerDependencies": {
"react": "^15.5.x || ^16.x || ^17.x || ^18.x",
"react-dom": "^15.5.x || ^16.x || ^17.x || ^18.x"
}
},
"node_modules/react-overlays": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz",
@@ -23393,4 +23407,4 @@
}
}
}
}
}

View File

@@ -80,6 +80,7 @@
"moment": "^2.29.4",
"moment-shortformat": "^2.1.0",
"react-dropzone": "^14.2.3",
"react-onclickoutside": "^6.13.0",
"react-redux": "^7.2.8",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.2",
@@ -98,7 +99,7 @@
"@edx/frontend-platform": "^4.0.0",
"@edx/paragon": "^20.27.0",
"prop-types": "^15.5.10",
"react": "^16.14.0 || ^17.0.0",
"react": "^16.14.0 || ^17.0.0",
"react-dom": "^16.14.0 || ^17.0.0"
}
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash-es';
import { Form } from '@edx/paragon';
const FormGroup = (props) => {
const handleFocus = (e) => {
if (props.handleFocus) { props.handleFocus(e); }
};
const handleClick = (e) => {
if (props.handleClick) { props.handleClick(e); }
};
const handleOnBlur = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
if (props.handleBlur) { props.handleBlur(e); }
}
};
return (
<Form.Group
isInvalid={!!props.errorMessage}
onBlur={handleOnBlur}
className={props.className}
>
<Form.Control
data-testid="formControl"
aria-invalid={props.errorMessage}
autoComplete={props.autoComplete ? 'on' : 'off'}
onChange={props.handleChange}
onFocus={handleFocus}
onClick={handleClick}
{...props}
>
{props.options ? props.options() : null}
</Form.Control>
{props.children}
{props.helpText && _.isEmpty(props.errorMessage) && (
<Form.Control.Feedback type="default" key="help-text">
{props.helpText}
</Form.Control.Feedback>
)}
{!_.isEmpty(props.errorMessage) && (
<Form.Control.Feedback
type="invalid"
key="error"
feedback-for={props.name}
data-testid="errorMessage"
>
{props.errorMessage}
</Form.Control.Feedback>
)}
</Form.Group>
);
};
FormGroup.defaultProps = {
as: 'input',
errorMessage: '',
autoComplete: null,
readOnly: false,
handleBlur: null,
handleChange: () => {},
handleFocus: null,
handleClick: null,
helpText: '',
placeholder: '',
options: null,
trailingElement: null,
type: 'text',
children: null,
className: '',
controlClassName: '',
};
FormGroup.propTypes = {
as: PropTypes.string,
errorMessage: PropTypes.string,
autoComplete: PropTypes.string,
readOnly: PropTypes.bool,
floatingLabel: PropTypes.string.isRequired,
handleBlur: PropTypes.func,
handleChange: PropTypes.func,
handleFocus: PropTypes.func,
handleClick: PropTypes.func,
helpText: PropTypes.string,
placeholder: PropTypes.string,
name: PropTypes.string.isRequired,
options: PropTypes.func,
trailingElement: PropTypes.element,
type: PropTypes.string,
value: PropTypes.string.isRequired,
children: PropTypes.element,
className: PropTypes.string,
controlClassName: PropTypes.string,
};
export default FormGroup;

View File

@@ -0,0 +1,83 @@
import {
fireEvent,
render,
screen,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/extend-expect';
import FormGroup from './FormGroup';
jest.unmock('@edx/paragon');
jest.unmock('@edx/paragon/icons');
const mockHandleChange = jest.fn();
const mockHandleFocus = jest.fn();
const mockHandleClick = jest.fn();
const mockHandleBlur = jest.fn();
const defaultProps = {
as: 'input',
errorMessage: '',
borderClass: '',
autoComplete: null,
readOnly: false,
handleBlur: mockHandleBlur,
handleChange: mockHandleChange,
handleFocus: mockHandleFocus,
handleClick: mockHandleClick,
helpText: 'helpText text',
options: null,
trailingElement: null,
type: 'text',
children: null,
className: '',
floatingLabel: 'floatingLabel text',
name: 'title',
value: '',
};
const renderComponent = (props) => render(<FormGroup {...props} />);
describe('FormGroup', () => {
it('renders component without error', () => {
renderComponent(defaultProps);
expect(screen.getByText(defaultProps.floatingLabel)).toBeVisible();
expect(screen.getByText(defaultProps.helpText)).toBeVisible();
expect(screen.queryByTestId('errorMessage')).toBeNull();
});
it('renders component with error', () => {
const newProps = {
...defaultProps,
errorMessage: 'error message',
};
renderComponent(newProps);
expect(screen.getByText(defaultProps.floatingLabel)).toBeVisible();
expect(screen.getByText(newProps.errorMessage)).toBeVisible();
expect(screen.queryByText(defaultProps.helpText)).toBeNull();
});
it('handles element focus', async () => {
renderComponent(defaultProps);
const formInput = screen.getByTestId('formControl');
fireEvent.focus(formInput);
expect(mockHandleFocus).toHaveBeenCalled();
});
it('handles element blur', () => {
renderComponent(defaultProps);
const formInput = screen.getByTestId('formControl');
fireEvent.focus(formInput);
fireEvent.focusOut(formInput);
expect(mockHandleBlur).toHaveBeenCalled();
});
it('handles element click', () => {
renderComponent(defaultProps);
const formInput = screen.getByTestId('formControl');
fireEvent.click(formInput);
expect(mockHandleClick).toHaveBeenCalled();
});
it('handles element change', () => {
renderComponent(defaultProps);
const formInput = screen.getByTestId('formControl');
fireEvent.focus(formInput);
userEvent.type(formInput, 'opt1');
expect(mockHandleChange).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,268 @@
import React from 'react';
import {
Icon,
IconButton,
Button,
ActionRow,
} from '@edx/paragon';
import { Add, ExpandLess, ExpandMore } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
// eslint-disable-next-line import/no-unresolved
import onClickOutside from 'react-onclickoutside';
import FormGroup from './FormGroup';
class TypeaheadDropdown extends React.Component {
constructor(props) {
super(props);
this.state = {
isFocused: false,
displayValue: '',
icon: this.expandMoreButton(),
dropDownItems: [],
};
this.handleFocus = this.handleFocus.bind(this);
this.handleOnBlur = this.handleOnBlur.bind(this);
}
shouldComponentUpdate(nextProps) {
if (this.props.value !== nextProps.value && nextProps.value !== '') {
const opt = this.props.options.find((o) => o === nextProps.value);
if (opt && opt !== this.state.displayValue) {
this.setState({ displayValue: opt });
}
return false;
}
return true;
}
// eslint-disable-next-line react/sort-comp
getItems(strToFind = '') {
let { options } = this.props;
if (strToFind.length > 0) {
options = options.filter((option) => (option.toLowerCase().includes(strToFind.toLowerCase())));
}
return options.map((opt) => {
let value = opt;
if (value.length > 30) {
value = value.substring(0, 30).concat('...');
}
return (
<button
type="button"
className="dropdown-item data-hj-suppress"
value={value}
key={value}
onClick={(e) => { this.handleItemClick(e); }}
>
{value}
</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 === value);
if (opt && opt !== this.state.displayValue) {
this.setState({ displayValue: opt });
}
}
setDisplayValue(value) {
const normalized = value.toLowerCase();
const opt = this.props.options.find((o) => o.toLowerCase() === normalized);
if (opt) {
this.setValue(opt);
this.setState({ displayValue: opt });
} else {
this.setValue('');
this.setState({ displayValue: value });
}
}
handleClick = (e) => {
const dropDownItems = this.getItems(e.target.value);
if (dropDownItems.length > 1) {
this.setState({ dropDownItems, icon: this.expandLessButton() });
}
if (this.state.dropDownItems.length > 0) {
this.setState({ dropDownItems: '', icon: this.expandMoreButton() });
}
};
handleOnChange = (e) => {
const findstr = e.target.value;
if (findstr.length) {
const filteredItems = this.getItems(findstr);
this.setState({ dropDownItems: filteredItems, icon: this.expandLessButton() });
} else {
this.setState({ dropDownItems: '', icon: this.expandMoreButton() });
}
this.setDisplayValue(e.target.value);
};
// eslint-disable-next-line react/no-unused-class-component-methods
handleClickOutside = () => {
if (this.state.dropDownItems.length > 0) {
this.setState(() => ({
icon: this.expandMoreButton(),
dropDownItems: '',
}));
}
};
handleExpandLess() {
this.setState({ dropDownItems: '', icon: this.expandMoreButton() });
}
handleExpandMore(e) {
const dropDownItems = this.getItems(e.target.value);
this.setState({ dropDownItems, icon: this.expandLessButton() });
}
handleFocus(e) {
this.setState({ isFocused: true });
if (this.props.handleFocus) { this.props.handleFocus(e); }
}
handleOnBlur(e) {
this.setState({ isFocused: false });
if (this.props.handleBlur) { this.props.handleBlur(e); }
}
handleItemClick(e) {
this.setValue(e.target.value);
this.setState({ dropDownItems: '', icon: this.expandMoreButton() });
}
expandMoreButton() {
return (
<IconButton
className="expand-more"
data-testid="expand-more-button"
src={ExpandMore}
iconAs={Icon}
size="sm"
variant="secondary"
alt="expand-more"
onClick={(e) => { this.handleExpandMore(e); }}
/>
);
}
expandLessButton() {
return (
<IconButton
className="expand-less"
data-testid="expand-less-button"
src={ExpandLess}
iconAs={Icon}
size="sm"
variant="secondary"
alt="expand-less"
onClick={(e) => { this.handleExpandLess(e); }}
/>
);
}
render() {
const noOptionsMessage = (
<ActionRow className="p-2 pl-3">
<div className="muted">{this.props.noOptionsMessage}</div>
<ActionRow.Spacer />
{this.props.allowNewOption && (
<Button
data-testid="add-option-button"
iconBefore={Add}
onClick={this.props.addNewOption}
>
{this.props.newOptionButtonLabel}
</Button>
)}
</ActionRow>
);
const dropDownEmptyList = this.state.dropDownItems && this.state.isFocused ? noOptionsMessage : null;
return (
<div className="dropdown-group-wrapper">
<FormGroup
name={this.props.name}
type="text"
value={this.state.displayValue}
readOnly={this.props.readOnly}
controlClassName={this.props.controlClassName}
errorMessage={this.props.errorMessage}
trailingElement={this.state.icon}
floatingLabel={this.props.floatingLabel}
placeholder={this.props.placeholder}
helpText={this.props.helpMessage}
handleChange={this.handleOnChange}
handleClick={this.handleClick}
handleBlur={this.handleOnBlur}
handleFocus={this.handleFocus}
isFocused={this.state.isFocused}
>
<div
data-testid="dropdown-container"
className="dropdown-container mt-2 rounded bg-light-100 box-shadow-centered-1 mr-2"
style={{ maxHeight: '300px', overflowY: 'scroll' }}
>
{ this.state.dropDownItems.length > 0 ? this.state.dropDownItems : dropDownEmptyList }
</div>
</FormGroup>
</div>
);
}
}
TypeaheadDropdown.defaultProps = {
options: null,
floatingLabel: null,
handleFocus: null,
handleChange: null,
handleBlur: null,
helpMessage: '',
placeholder: '',
value: null,
errorMessage: null,
readOnly: false,
controlClassName: '',
allowNewOption: false,
newOptionButtonLabel: '',
addNewOption: null,
};
TypeaheadDropdown.propTypes = {
noOptionsMessage: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
options: PropTypes.arrayOf(PropTypes.string),
floatingLabel: PropTypes.string,
handleFocus: PropTypes.func,
handleChange: PropTypes.func,
handleBlur: PropTypes.func,
helpMessage: PropTypes.string,
placeholder: PropTypes.string,
value: PropTypes.string,
errorMessage: PropTypes.string,
readOnly: PropTypes.bool,
controlClassName: PropTypes.string,
allowNewOption: PropTypes.bool,
newOptionButtonLabel: PropTypes.string,
addNewOption: PropTypes.func,
};
export default onClickOutside(TypeaheadDropdown);

View File

@@ -0,0 +1,136 @@
import {
act,
fireEvent,
render,
screen,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/extend-expect';
import TypeaheadDropdown from '.';
jest.unmock('@edx/paragon');
jest.unmock('@edx/paragon/icons');
const defaultProps = {
as: 'input',
name: 'OrganizationDropdown',
floatingLabel: 'floatingLabel text',
options: null,
handleFocus: null,
handleChange: null,
handleBlur: null,
value: null,
errorMessage: null,
errorCode: null,
readOnly: false,
noOptionsMessage: 'No options',
};
const renderComponent = (props) => render(<TypeaheadDropdown {...props} />);
describe('common/OrganizationDropdown.jsx', () => {
it('renders component without error', () => {
renderComponent(defaultProps);
expect(screen.getByText(defaultProps.floatingLabel)).toBeVisible();
});
it('handles element focus', () => {
const mockHandleFocus = jest.fn();
const newProps = { ...defaultProps, handleFocus: mockHandleFocus };
renderComponent(newProps);
const formInput = screen.getByTestId('formControl');
fireEvent.focus(formInput);
expect(mockHandleFocus).toHaveBeenCalled();
});
it('handles element blur', () => {
const mockHandleBlur = jest.fn();
const newProps = { ...defaultProps, handleBlur: mockHandleBlur };
renderComponent(newProps);
const formInput = screen.getByTestId('formControl');
fireEvent.focus(formInput);
fireEvent.focusOut(formInput);
expect(mockHandleBlur).toHaveBeenCalled();
});
it('renders component with options', () => {
const newProps = { ...defaultProps, options: ['opt1', 'opt2'] };
renderComponent(newProps);
const formInput = screen.getByTestId('formControl');
fireEvent.click(formInput);
const optionsList = within(screen.getByTestId('dropdown-container')).getAllByRole('button');
expect(optionsList.length).toEqual(newProps.options.length);
});
it('selects option', () => {
const newProps = { ...defaultProps, options: ['opt1', 'opt2'] };
renderComponent(newProps);
const formInput = screen.getByTestId('formControl');
fireEvent.click(formInput);
const optionsList = within(screen.getByTestId('dropdown-container')).getAllByRole('button');
fireEvent.click(optionsList.at([0]));
expect(formInput.value).toEqual(newProps.options[0]);
});
it('toggles options list', async () => {
const newProps = { ...defaultProps, options: ['opt1', 'opt2'] };
renderComponent(newProps);
const optionsList = within(screen.getByTestId('dropdown-container')).queryAllByRole('button');
expect(optionsList.length).toEqual(0);
await act(async () => {
fireEvent.click(screen.getByTestId('expand-more-button'));
});
expect(within(screen.getByTestId('dropdown-container'))
.queryAllByRole('button').length).toEqual(newProps.options.length);
await act(async () => {
fireEvent.click(screen.getByTestId('expand-less-button'));
});
expect(within(screen.getByTestId('dropdown-container'))
.queryAllByRole('button').length).toEqual(0);
});
it('shows options list depends on field value', () => {
const newProps = { ...defaultProps, options: ['opt1', 'opt2'] };
renderComponent(newProps);
const formInput = screen.getByTestId('formControl');
fireEvent.focus(formInput);
userEvent.type(formInput, 'opt1');
expect(within(screen.getByTestId('dropdown-container'))
.queryAllByRole('button').length).toEqual(1);
});
it('closes options list on click outside', async () => {
const newProps = { ...defaultProps, options: ['opt1', 'opt2'] };
renderComponent(newProps);
const formInput = screen.getByTestId('formControl');
fireEvent.click(formInput);
expect(within(screen.getByTestId('dropdown-container'))
.queryAllByRole('button').length).toEqual(2);
userEvent.click(document.body);
expect(within(screen.getByTestId('dropdown-container'))
.queryAllByRole('button').length).toEqual(0);
});
describe('empty options list', () => {
it('shows empty options list depends on field value', () => {
const newProps = { ...defaultProps, options: ['opt1', 'opt2'] };
renderComponent(newProps);
const formInput = screen.getByTestId('formControl');
fireEvent.focus(formInput);
userEvent.type(formInput, '3');
const noOptionsList = within(screen.getByTestId('dropdown-container')).getByText('No options');
const addButton = within(screen.getByTestId('dropdown-container')).queryByTestId('add-option-button');
expect(noOptionsList).toBeVisible();
expect(addButton).toBeNull();
});
it('shows empty options list with add option button', () => {
const newProps = {
...defaultProps,
options: ['opt1', 'opt2'],
allowNewOption: true,
newOptionButtonLabel: 'Add new option',
addNewOption: jest.fn(),
};
renderComponent(newProps);
const formInput = screen.getByTestId('formControl');
fireEvent.focus(formInput);
userEvent.type(formInput, '3');
const noOptionsList = within(screen.getByTestId('dropdown-container')).getByText('No options');
expect(noOptionsList).toBeVisible();
const addButton = within(screen.getByTestId('dropdown-container')).getByTestId('add-option-button');
expect(addButton).toHaveTextContent(newProps.newOptionButtonLabel);
});
});
});

View File

@@ -7,6 +7,7 @@ import ErrorAlert from './editors/sharedComponents/ErrorAlerts/ErrorAlert';
import Footer from './footer';
import { TinyMceWidget } from './editors/sharedComponents/TinyMceWidget';
import { prepareEditorRef } from './editors/sharedComponents/TinyMceWidget/hooks';
import TypeaheadDropdown from './editors/sharedComponents/TypeaheadDropdown';
export {
messages,
@@ -18,5 +19,6 @@ export {
Footer,
TinyMceWidget,
prepareEditorRef,
TypeaheadDropdown,
};
export default Placeholder;