From 3819b56b4b39ca831a1d30cd924725f1ebcced4f Mon Sep 17 00:00:00 2001 From: Michael Baghel Date: Wed, 24 Feb 2021 12:00:30 +0400 Subject: [PATCH] Updated to use react-select's AsyncCreatableSelect for getting suggestions in AddModal - Removed CreatableSelect from ReactSelect component - Created new AsyncSelect component - Decoupled suggestion state from redux - Allow AsyncSelect to handle its own state --- package-lock.json | 35 ++++++ src/components/AsyncSelect/index.jsx | 111 ++++++++++++++++++ src/components/AsyncSelect/styles.module.scss | 18 +++ src/components/ReactSelect/index.jsx | 47 +++----- src/routes/TeamAccess/actions/index.js | 26 ---- .../TeamAccess/components/AddModal/index.jsx | 77 ++++++------ src/routes/TeamAccess/reducers/index.js | 29 ----- 7 files changed, 220 insertions(+), 123 deletions(-) create mode 100644 src/components/AsyncSelect/index.jsx create mode 100644 src/components/AsyncSelect/styles.module.scss diff --git a/package-lock.json b/package-lock.json index 85a59a66..84f77334 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2800,6 +2800,15 @@ "@types/testing-library__react": "^9.1.2" } }, + "@toast-ui/editor": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@toast-ui/editor/-/editor-2.5.1.tgz", + "integrity": "sha512-LVNo/YaNItUemEaRFvFAVn7w/0U7yxEheMdn6GEGxqo727rRZD1MH7OTDVq6NeQ+P93VwFpa0i9GGRBhNNEbPQ==", + "requires": { + "@types/codemirror": "0.0.71", + "codemirror": "^5.48.4" + } + }, "@types/anymatch": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz", @@ -2847,6 +2856,19 @@ "@babel/types": "^7.3.0" } }, + "@types/codemirror": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.71.tgz", + "integrity": "sha512-b2oEEnno1LIGKMR7uBEsr40al1UijF1HEpRn0+Yf1xOLl24iQgB7DBpZVMM7y54G5wCNoclDrRO65E6KHPNO2w==", + "requires": { + "@types/tern": "*" + } + }, + "@types/estree": { + "version": "0.0.46", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.46.tgz", + "integrity": "sha512-laIjwTQaD+5DukBZaygQ79K1Z0jb1bPEMRrkXSLjtCcZm+abyp5YbrqpSLzD42FwWW6gK/aS4NYpJ804nG2brg==" + }, "@types/glob": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz", @@ -2991,6 +3013,14 @@ "integrity": "sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA==", "dev": true }, + "@types/tern": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.3.tgz", + "integrity": "sha512-imDtS4TAoTcXk0g7u4kkWqedB3E4qpjXzCpD2LU5M5NAXHzCDsypyvXSaG7mM8DKYkCRa7tFp4tS/lp/Wo7Q3w==", + "requires": { + "@types/estree": "*" + } + }, "@types/testing-library__dom": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/testing-library__dom/-/testing-library__dom-6.14.0.tgz", @@ -4942,6 +4972,11 @@ "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true }, + "codemirror": { + "version": "5.59.3", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.59.3.tgz", + "integrity": "sha512-p1d4BjmBBssgnEGtQeWvE5PdiDffqZjiJ77h2FZ2J2BpW9qdOzf6v7IQscyE+TgyKBQS3PpsYimfEDNgcNRZGQ==" + }, "collect-v8-coverage": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", diff --git a/src/components/AsyncSelect/index.jsx b/src/components/AsyncSelect/index.jsx new file mode 100644 index 00000000..269ad803 --- /dev/null +++ b/src/components/AsyncSelect/index.jsx @@ -0,0 +1,111 @@ +/** + * AsyncSelect + * + * A wrapper for react-select's AsyncCreatableSelect. + */ +import React from "react"; +import PT from "prop-types"; +import AsyncCreatableSelect from "react-select/async-creatable"; +import "./styles.module.scss"; + +const AsyncSelect = (props) => { + const customStyles = { + control: (provided, state) => ({ + ...provided, + minHeight: "40px", + border: "1px solid #aaaaab", + borderColor: state.isFocused ? "#55a5ff" : "#aaaaab", + boxShadow: state.isFocused ? "0 0 2px 1px #cee6ff" : provided.boxShadow, + }), + menu: (provided) => ({ + ...provided, + minHeight: "40px", + zIndex: 10, + }), + valueContainer: (provided) => ({ + ...provided, + padding: "2px 6px", + }), + input: (provided) => ({ + ...provided, + margin: "0px", + height: "auto", + padding: "0", + }), + indicatorSeparator: () => ({ + display: "none", + }), + indicatorsContainer: (provided) => ({ + ...provided, + height: "auto", + }), + option: (provided) => ({ + ...provided, + minHeight: "32px", + }), + placeholder: (provided) => ({ + ...provided, + color: "#AAAAAA", + fontFamily: "Roboto", + fontSize: "14px", + lineHeight: "22px", + textAlign: "left", + fontWeight: "400", + }), + multiValue: (provided) => ({ + ...provided, + margin: "3px 3px", + color: "#AAAAAA", + fontFamily: "Roboto", + fontSize: "14px", + lineHeight: "22px", + textAlign: "left", + borderRadius: "5px", + }), + dropdownIndicator: () => ({ + display: "none", + }), + }; + + return ( +
+ props.noOptionsText} + loadingMessage={() => props.loadingText} + isDisabled={props.disabled} + cacheOptions={props.cacheOptions} + loadOptions={props.loadOptions} + defaultOptions={props.defaultOptions} + /> +
+ ) +} + +AsyncSelect.propTypes = { + value: PT.string, + onChange: PT.func, + placeholder: PT.string, + error: PT.string, + isMulti: PT.bool, + onBlur: PT.func, + onFocus: PT.func, + onInputChange: PT.func, + cacheOptions: PT.bool, + onInputChange: PT.func, + noOptionsText: PT.string, + loadingText: PT.string, + loadOptions: PT.func, + defaultOptions: PT.bool || PT.array, + disabled: PT.bool, +} + +export default AsyncSelect; \ No newline at end of file diff --git a/src/components/AsyncSelect/styles.module.scss b/src/components/AsyncSelect/styles.module.scss new file mode 100644 index 00000000..96ffd0d3 --- /dev/null +++ b/src/components/AsyncSelect/styles.module.scss @@ -0,0 +1,18 @@ +.error { + :first-child { + border-color: #ff5b52; + } +} + +.select-wrapper { + input { + border: none !important; + box-shadow: none !important; + transition: none !important; + height: 28px; + } +} + +.react-select__option { + min-height: 32px; +} diff --git a/src/components/ReactSelect/index.jsx b/src/components/ReactSelect/index.jsx index 567b70b2..1101fe59 100644 --- a/src/components/ReactSelect/index.jsx +++ b/src/components/ReactSelect/index.jsx @@ -70,38 +70,20 @@ const ReactSelect = (props) => { return (
- {props.isCreatable ? ( - props.noOptionsText} - createOptionPosition="first" - isDisabled={props.disabled} - /> - ) : ( - props.noOptionsText} + isDisabled={props.disabled} + />
); }; @@ -121,7 +103,6 @@ ReactSelect.propTypes = { label: PT.string.isRequired, }).isRequired ), - isCreatable: PT.bool, noOptionsText: PT.string, disabled: PT.bool, }; diff --git a/src/routes/TeamAccess/actions/index.js b/src/routes/TeamAccess/actions/index.js index 4dd31276..8b062e39 100644 --- a/src/routes/TeamAccess/actions/index.js +++ b/src/routes/TeamAccess/actions/index.js @@ -6,7 +6,6 @@ import { getTeamMembers, getTeamInvitees, deleteTeamMember, - getMemberSuggestions, postMembers, } from "services/teams"; import { ACTION_TYPE } from "constants"; @@ -74,31 +73,6 @@ export const removeTeamMember = (teamId, memberId) => ({ }, }); -/** - * Loads suggestions for invites - * - * @param {string} fragment - * - * @returns {Promise} list of suggestions or error - */ -export const loadSuggestions = (fragment) => ({ - type: ACTION_TYPE.LOAD_MEMBERS_SUGGESTIONS, - payload: async () => { - const res = await getMemberSuggestions(fragment); - return res.data.result.content; - }, - meta: { - fragment, - }, -}); - -/** - * Clears invite suggestions - */ -export const clearSuggestions = () => ({ - type: ACTION_TYPE.CLEAR_MEMBERS_SUGGESTIONS, -}); - /** * Adds members to team * diff --git a/src/routes/TeamAccess/components/AddModal/index.jsx b/src/routes/TeamAccess/components/AddModal/index.jsx index 9ab5b696..71b22a57 100644 --- a/src/routes/TeamAccess/components/AddModal/index.jsx +++ b/src/routes/TeamAccess/components/AddModal/index.jsx @@ -1,17 +1,46 @@ import React, { useCallback, useState } from "react"; import _ from "lodash"; import PT from "prop-types"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch } from "react-redux"; import { toastr } from "react-redux-toastr"; -import { loadSuggestions, clearSuggestions, addMembers } from "../../actions"; +import { addMembers } from "../../actions"; import Button from "components/Button"; import BaseModal from "components/BaseModal"; -import ReactSelect from "components/ReactSelect"; +import AsyncSelect from "components/AsyncSelect"; import "./styles.module.scss"; import { formatPlural } from "utils/format"; +import { getMemberSuggestions } from "services/teams"; -// Minimum length of input for suggestions to trigger -const SUGGESTION_TRIGGER_LENGTH = 3; +/** + * Fetches suggestions based on input in select box + * @param {string} inputVal Input from select + * + * @returns {Promise} A promise that resolves to list of suggested users + */ +const loadSuggestions = inputVal => { + return getMemberSuggestions(inputVal) + .then(res => { + const users = _.get(res, "data.result.content", []); + return users.map(user => ({ + label: user.handle, + value: user.handle + })) + }) + .catch(() => { + console.warn("could not get suggestions"); + return []; + }) +} + +/** + * Function to call if user does not have permission to see suggestions + * @returns {Promise} Promise resolving to empty array + */ +const emptySuggestions = () => { + return new Promise(resolve => { + resolve([]); + }) +} /** * Filters selected members, keeping those who could not be added to team @@ -59,21 +88,8 @@ const AddModal = ({ open, onClose, teamId, validateAdds, showSuggestions }) => { const [validationError, setValidationError] = useState(false); const [responseErrors, setResponseErrors] = useState([]); const [selectedMembers, setSelectedMembers] = useState([]); - const options = useSelector((state) => - state.teamMembers.suggestions.map((sugg) => ({ - label: sugg.handle, - value: sugg.handle, - })) - ); - const dispatch = useDispatch(); - const debouncedLoadSuggestions = _.debounce( - (arg) => { - dispatch(loadSuggestions(arg)); - }, - 500, - { leading: true } - ); + const dispatch = useDispatch(); const handleClose = useCallback(() => { setSelectedMembers([]); @@ -142,17 +158,8 @@ const AddModal = ({ open, onClose, teamId, validateAdds, showSuggestions }) => { onUpdate([...selectedMembers, { label: val, value: val }]); return ""; } - - // load suggestions if role allows - if (showSuggestions) { - if (val.length >= SUGGESTION_TRIGGER_LENGTH) { - debouncedLoadSuggestions(val); - } else { - dispatch(clearSuggestions()); - } - } }, - [dispatch, selectedMembers, showSuggestions] + [selectedMembers] ); const onUpdate = useCallback( @@ -170,10 +177,8 @@ const AddModal = ({ open, onClose, teamId, validateAdds, showSuggestions }) => { else setValidationError(false); setResponseErrors([]); - - dispatch(clearSuggestions()); }, - [dispatch, validateAdds] + [validateAdds] ); const addButton = ( @@ -196,15 +201,17 @@ const AddModal = ({ open, onClose, teamId, validateAdds, showSuggestions }) => { disabled={loading} extraModalStyle={{ overflowY: "visible" }} > - {validationError && (
diff --git a/src/routes/TeamAccess/reducers/index.js b/src/routes/TeamAccess/reducers/index.js index 5be42a8f..66b736d5 100644 --- a/src/routes/TeamAccess/reducers/index.js +++ b/src/routes/TeamAccess/reducers/index.js @@ -6,7 +6,6 @@ import { ACTION_TYPE } from "constants"; const initialState = { members: undefined, invites: undefined, - suggestions: [], loading: false, error: undefined, updating: false, @@ -84,34 +83,6 @@ const reducer = (state = initialState, action) => { error: action.payload, }; - case ACTION_TYPE.LOAD_MEMBERS_SUGGESTIONS_PENDING: - return { - ...state, - loading: true, - error: undefined, - }; - - case ACTION_TYPE.LOAD_MEMBERS_SUGGESTIONS_SUCCESS: - return { - ...state, - suggestions: action.payload, - loading: false, - error: undefined, - }; - - case ACTION_TYPE.LOAD_MEMBERS_SUGGESTIONS_ERROR: - return { - ...state, - loading: false, - error: action.payload, - }; - - case ACTION_TYPE.CLEAR_MEMBERS_SUGGESTIONS: - return { - ...state, - suggestions: [], - }; - case ACTION_TYPE.ADD_MEMBERS_PENDING: return { ...state,