From 9438a5b89aeacc2f1b3d7bdbccf2359e9d8ac616 Mon Sep 17 00:00:00 2001
From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
Date: Wed, 16 Aug 2023 18:18:06 -0400
Subject: [PATCH] feat: add typeahead component (#375)
---
package-lock.json | 16 +-
package.json | 3 +-
.../TypeaheadDropdown/FormGroup.jsx | 99 +++++++
.../TypeaheadDropdown/FormGroup.test.jsx | 83 ++++++
.../TypeaheadDropdown/index.jsx | 268 ++++++++++++++++++
.../TypeaheadDropdown/index.test.jsx | 136 +++++++++
src/index.jsx | 2 +
7 files changed, 605 insertions(+), 2 deletions(-)
create mode 100644 src/editors/sharedComponents/TypeaheadDropdown/FormGroup.jsx
create mode 100644 src/editors/sharedComponents/TypeaheadDropdown/FormGroup.test.jsx
create mode 100644 src/editors/sharedComponents/TypeaheadDropdown/index.jsx
create mode 100644 src/editors/sharedComponents/TypeaheadDropdown/index.test.jsx
diff --git a/package-lock.json b/package-lock.json
index 86ba5eba6..29fe0cd01 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/package.json b/package.json
index a3c752018..acc6501a2 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.jsx b/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.jsx
new file mode 100644
index 000000000..e6489320b
--- /dev/null
+++ b/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.jsx
@@ -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 (
+
+
+ {props.options ? props.options() : null}
+
+
+ {props.children}
+
+ {props.helpText && _.isEmpty(props.errorMessage) && (
+
+ {props.helpText}
+
+ )}
+
+ {!_.isEmpty(props.errorMessage) && (
+
+ {props.errorMessage}
+
+ )}
+
+ );
+};
+
+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;
diff --git a/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.test.jsx b/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.test.jsx
new file mode 100644
index 000000000..b7e995dd4
--- /dev/null
+++ b/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.test.jsx
@@ -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();
+
+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();
+ });
+});
diff --git a/src/editors/sharedComponents/TypeaheadDropdown/index.jsx b/src/editors/sharedComponents/TypeaheadDropdown/index.jsx
new file mode 100644
index 000000000..0a9b521be
--- /dev/null
+++ b/src/editors/sharedComponents/TypeaheadDropdown/index.jsx
@@ -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 (
+
+ );
+ });
+ }
+
+ 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 (
+ { this.handleExpandMore(e); }}
+ />
+ );
+ }
+
+ expandLessButton() {
+ return (
+ { this.handleExpandLess(e); }}
+ />
+ );
+ }
+
+ render() {
+ const noOptionsMessage = (
+
+ {this.props.noOptionsMessage}
+
+ {this.props.allowNewOption && (
+
+ )}
+
+ );
+ const dropDownEmptyList = this.state.dropDownItems && this.state.isFocused ? noOptionsMessage : null;
+ return (
+
+
+
+ { this.state.dropDownItems.length > 0 ? this.state.dropDownItems : dropDownEmptyList }
+
+
+
+ );
+ }
+}
+
+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);
diff --git a/src/editors/sharedComponents/TypeaheadDropdown/index.test.jsx b/src/editors/sharedComponents/TypeaheadDropdown/index.test.jsx
new file mode 100644
index 000000000..324ddc677
--- /dev/null
+++ b/src/editors/sharedComponents/TypeaheadDropdown/index.test.jsx
@@ -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();
+
+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);
+ });
+ });
+});
diff --git a/src/index.jsx b/src/index.jsx
index 245c375de..27bb984d9 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -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;