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;