diff --git a/lib/CONST.d.ts b/lib/CONST.d.ts index 4351ca7c..77974b46 100644 --- a/lib/CONST.d.ts +++ b/lib/CONST.d.ts @@ -254,11 +254,11 @@ export declare const CONST: { /** * Regex matching a text containing general phone number */ - readonly GENERAL_PHONE_PART: RegExp, + readonly GENERAL_PHONE_PART: RegExp; /** - * Regex matching a text containing an E.164 format phone number - */ - readonly PHONE_PART: "\\+[1-9]\\d{1,14}"; + * Regex matching a text containing an E.164 format phone number + */ + readonly PHONE_PART: '\\+[1-9]\\d{1,14}'; /** * Regular expression to check that a basic name is valid */ diff --git a/lib/CONST.jsx b/lib/CONST.jsx index 49cebfb6..b68261b4 100644 --- a/lib/CONST.jsx +++ b/lib/CONST.jsx @@ -291,15 +291,15 @@ export const CONST = { EMAIL_PART: EMAIL_BASE_REGEX, /** - * Regex matching a text containing general phone number - * - * @type RegExp - */ + * Regex matching a text containing general phone number + * + * @type RegExp + */ GENERAL_PHONE_PART: /^(\+\d{1,2}\s?)?(\(\d{3}\)|\d{3})[\s.-]?\d{3}[\s.-]?\d{4}$/, /** - * Regex matching a text containing an E.164 format phone number - */ + * Regex matching a text containing an E.164 format phone number + */ PHONE_PART: '\\+[1-9]\\d{1,14}', /** @@ -368,7 +368,8 @@ export const CONST = { * * @type RegExp */ - EMOJI_RULE: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/gu, + EMOJI_RULE: + /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/gu, }, REPORT: { diff --git a/lib/ExpensiMark.js b/lib/ExpensiMark.js index 998acf48..79d62e8b 100644 --- a/lib/ExpensiMark.js +++ b/lib/ExpensiMark.js @@ -21,7 +21,7 @@ export default class ExpensiMark { { name: 'emoji', regex: Constants.CONST.REG_EXP.EMOJI_RULE, - replacement: match => `${match}` + replacement: (match) => `${match}`, }, /** @@ -124,7 +124,8 @@ export default class ExpensiMark { name: 'image', regex: MARKDOWN_IMAGE_REGEX, replacement: (match, g1, g2) => `${this.escapeAttributeContent(g1)}`, - rawInputReplacement: (match, g1, g2) => `${this.escapeAttributeContent(g1)}` + rawInputReplacement: (match, g1, g2) => + `${this.escapeAttributeContent(g1)}`, }, /** @@ -191,7 +192,10 @@ export default class ExpensiMark { */ { name: 'userMentions', - regex: new RegExp(`(@here|[a-zA-Z0-9.!$%&+=?^\`{|}-]?)(@${Constants.CONST.REG_EXP.EMAIL_PART}|@${Constants.CONST.REG_EXP.PHONE_PART})(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, 'gim'), + regex: new RegExp( + `(@here|[a-zA-Z0-9.!$%&+=?^\`{|}-]?)(@${Constants.CONST.REG_EXP.EMAIL_PART}|@${Constants.CONST.REG_EXP.PHONE_PART})(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, + 'gim', + ), replacement: (match, g1, g2) => { if (!Str.isValidMention(match)) { return match; @@ -221,10 +225,7 @@ export default class ExpensiMark { name: 'autolink', process: (textToProcess, replacement) => { - const regex = new RegExp( - `(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>|.+\\/>))`, - 'gi', - ); + const regex = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>|.+\\/>))`, 'gi'); return this.modifyTextForUrlLinks(regex, textToProcess, replacement); }, @@ -235,7 +236,7 @@ export default class ExpensiMark { rawInputReplacement: (_match, g1, g2) => { const href = Str.sanitizeURL(g2); return `${g1}${g2}${g1}`; - } + }, }, { @@ -246,8 +247,9 @@ export default class ExpensiMark { // inline code blocks. A single prepending space should be stripped if it exists process: (textToProcess, replacement, shouldKeepRawInput = false) => { const regex = /^>[ >]+(?! )(?![^<]*(?:<\/pre>|<\/code>))([^\v\n\r]+)/gm; + const replaceFunction = (g1) => replacement(g1, shouldKeepRawInput); if (shouldKeepRawInput) { - return textToProcess.replace(regex, (g1) => replacement(g1, shouldKeepRawInput)); + return textToProcess.replace(regex, replaceFunction); } return this.modifyTextForQuote(regex, textToProcess, replacement); }, @@ -255,13 +257,14 @@ export default class ExpensiMark { // We want to enable 2 options of nested heading inside the blockquote: "># heading" and "> # heading". // To do this we need to parse body of the quote without first space let isStartingWithSpace = false; - const textToReplace = g1.replace(/^>( )?/gm, (match, g2) => { + const handleMatch = (match, g2) => { if (shouldKeepRawInput) { isStartingWithSpace = !!g2; return ''; } return match; - }); + }; + const textToReplace = g1.replace(/^>( )?/gm, handleMatch); const filterRules = ['heading1']; // if we don't reach the max quote depth we allow the recursive call to process possible quote @@ -273,7 +276,7 @@ export default class ExpensiMark { const replacedText = this.replace(textToReplace, { filterRules, shouldEscapeText: false, - shouldKeepRawInput + shouldKeepRawInput, }); this.currentQuoteDepth = 0; return `
${isStartingWithSpace ? ' ' : ''}${replacedText}
`; @@ -426,8 +429,8 @@ export default class ExpensiMark { .trim() .split('\n'); - resultString = _.map(resultString, (m) => `> ${m}`).join('\n'); - + const prependGreaterSign = (m) => `> ${m}`; + resultString = _.map(resultString, prependGreaterSign).join('\n'); // We want to keep
tag here and let method replaceBlockElementWithNewLine to handle the line break later return `
${resultString}
`; }, @@ -462,7 +465,7 @@ export default class ExpensiMark { } return `!(${g2})`; - } + }, }, { name: 'reportMentions', @@ -559,7 +562,7 @@ export default class ExpensiMark { name: 'stripTag', regex: /(<([^>]+)>)/gi, replacement: '', - } + }, ]; /** @@ -570,9 +573,16 @@ export default class ExpensiMark { /** * The list of rules that have to be applied when shouldKeepWhitespace flag is true. - * @type {Object[]} + * @param {Object} rule - The rule to check. + * @returns {boolean} Returns true if the rule should be applied, otherwise false. + */ + this.filterRules = (rule) => !_.includes(this.whitespaceRulesToDisable, rule.name); + + /** + * Filters rules to determine which should keep whitespace. + * @returns {Object[]} The filtered rules. */ - this.shouldKeepWhitespaceRules = _.filter(this.rules, (rule) => !_.includes(this.whitespaceRulesToDisable, rule.name)); + this.shouldKeepWhitespaceRules = _.filter(this.rules, this.filterRules); /** * maxQuoteDepth is the maximum depth of nested quotes that we want to support. @@ -589,14 +599,16 @@ export default class ExpensiMark { getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput) { let rules = this.rules; - if(shouldKeepRawInput) { + const hasRuleName = (rule) => _.contains(filterRules, rule.name); + const hasDisabledRuleName = (rule) => !_.contains(disabledRules, rule.name); + if (shouldKeepRawInput) { rules = this.shouldKeepWhitespaceRules; } if (!_.isEmpty(filterRules)) { - rules = _.filter(this.rules, (rule) => _.contains(filterRules, rule.name)); + rules = _.filter(this.rules, hasRuleName); } if (!_.isEmpty(disabledRules)) { - rules = _.filter(rules, (rule) => !_.contains(disabledRules, rule.name)); + rules = _.filter(rules, hasDisabledRuleName); } return rules; } @@ -619,24 +631,25 @@ export default class ExpensiMark { let replacedText = shouldEscapeText ? _.escape(text) : text; const rules = this.getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput); - try { - rules.forEach((rule) => { - // Pre-process text before applying regex - if (rule.pre) { - replacedText = rule.pre(replacedText); - } - const replacementFunction = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; - if (rule.process) { - replacedText = rule.process(replacedText, replacementFunction, shouldKeepRawInput); - } else { - replacedText = replacedText.replace(rule.regex, replacementFunction); - } + const processRule = (rule) => { + // Pre-process text before applying regex + if (rule.pre) { + replacedText = rule.pre(replacedText); + } + const replacementFunction = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; + if (rule.process) { + replacedText = rule.process(replacedText, replacementFunction, shouldKeepRawInput); + } else { + replacedText = replacedText.replace(rule.regex, replacementFunction); + } - // Post-process text after applying regex - if (rule.post) { - replacedText = rule.post(replacedText); - } - }); + // Post-process text after applying regex + if (rule.post) { + replacedText = rule.post(replacedText); + } + }; + try { + rules.forEach(processRule); } catch (e) { // eslint-disable-next-line no-console console.warn('Error replacing text with html in ExpensiMark.replace', {error: e}); @@ -811,7 +824,8 @@ export default class ExpensiMark { let splitText = htmlString.split( /|<\/div>||\n<\/comment>|<\/comment>|

|<\/h1>|

|<\/h2>|

|<\/h3>|

|<\/h4>|

|<\/h5>|
|<\/h6>|

|<\/p>|

  • |<\/li>|
    |<\/blockquote>/, ); - splitText = _.map(splitText, (text) => Str.stripHTML(text)); + const stripHTML = (text) => Str.stripHTML(text); + splitText = _.map(splitText, stripHTML); let joinedText = ''; // Delete whitespace at the end @@ -822,7 +836,7 @@ export default class ExpensiMark { splitText.pop(); } - splitText.forEach((text, index) => { + const processText = (text, index) => { if (text.trim().length === 0 && !text.match(/\n/)) { return; } @@ -833,7 +847,9 @@ export default class ExpensiMark { } else { joinedText += `${text}\n`; } - }); + }; + + splitText.forEach(processText); return joinedText; } @@ -856,7 +872,7 @@ export default class ExpensiMark { generatedMarkdown = parseBodyTag[2]; } - this.htmlToMarkdownRules.forEach((rule) => { + const processRule = (rule) => { // Pre-processes input HTML before applying regex if (rule.pre) { generatedMarkdown = rule.pre(generatedMarkdown); @@ -865,7 +881,9 @@ export default class ExpensiMark { // if replacement is a function, we want to pass optional extras to it const replacementFunction = typeof rule.replacement === 'function' ? (...args) => rule.replacement(...args, extras) : rule.replacement; generatedMarkdown = generatedMarkdown.replace(rule.regex, replacementFunction); - }); + }; + + this.htmlToMarkdownRules.forEach(processRule); return Str.htmlDecode(this.replaceBlockElementWithNewLine(generatedMarkdown)); } @@ -879,13 +897,13 @@ export default class ExpensiMark { */ htmlToText(htmlString, extras) { let replacedText = htmlString; - - this.htmlToTextRules.forEach((rule) => { - + const processRule = (rule) => { // if replacement is a function, we want to pass optional extras to it const replacementFunction = typeof rule.replacement === 'function' ? (...args) => rule.replacement(...args, extras) : rule.replacement; replacedText = replacedText.replace(rule.regex, replacementFunction); - }); + }; + + this.htmlToTextRules.forEach(processRule); // Unescaping because the text is escaped in 'replace' function // We use 'htmlDecode' instead of 'unescape' to replace entities like ' ' @@ -968,13 +986,14 @@ export default class ExpensiMark { formatTextForQuote(regex, textToCheck, replacement) { if (textToCheck.match(regex)) { // Remove '>' and trim the spaces between nested quotes - let textToFormat = _.map(textToCheck.split('\n'), (row) => { + const formatRow = (row) => { const quoteContent = row[4] === ' ' ? row.substr(5) : row.substr(4); if (quoteContent.trimStart().startsWith('>')) { return quoteContent.trimStart(); } return quoteContent; - }).join('\n'); + }; + let textToFormat = _.map(textToCheck.split('\n'), formatRow).join('\n'); // Remove leading and trailing line breaks textToFormat = textToFormat.replace(/^\n+|\n+$/g, ''); @@ -1029,13 +1048,13 @@ export default class ExpensiMark { extractLinksInMarkdownComment(comment) { try { const htmlString = this.replace(comment, {filterRules: ['link']}); - // We use same anchor tag template as link and autolink rules to extract link const regex = new RegExp(``, 'gi'); const matches = [...htmlString.matchAll(regex)]; // Element 1 from match is the regex group if it exists which contains the link URLs - const links = _.map(matches, (match) => Str.sanitizeURL(match[1])); + const sanitizeMatch = (match) => Str.sanitizeURL(match[1]); + const links = _.map(matches, sanitizeMatch); return links; } catch (e) { // eslint-disable-next-line no-console diff --git a/lib/components/StepProgressBar.js b/lib/components/StepProgressBar.js index 34373814..d682ef4a 100644 --- a/lib/components/StepProgressBar.js +++ b/lib/components/StepProgressBar.js @@ -23,37 +23,36 @@ const propTypes = { * @return {React.Component} */ function StepProgressBar({steps, currentStep}) { - const currentStepIndex = Math.max( - 0, - _.findIndex(steps, (step) => step.id === currentStep), - ); + const isCurrentStep = (step) => step.id === currentStep; + const currentStepIndex = Math.max(0, _.findIndex(steps, isCurrentStep)); + + const renderStep = (step, i) => { + let status = currentStepIndex === i ? UIConstants.UI.ACTIVE : ''; + if (currentStepIndex > i) { + status = 'complete'; + } + + return ( +
    +
    +
    +
    +
    + {step.title} +
    +
    + ); + }; + return (
    -
    - {_.map(steps, (step, i) => { - let status = currentStepIndex === i ? UIConstants.UI.ACTIVE : ''; - if (currentStepIndex > i) { - status = 'complete'; - } - - return ( -
    -
    -
    -
    -
    - {step.title} -
    -
    - ); - })} -
    +
    {_.map(steps, renderStep)}
    ); } diff --git a/lib/components/form/element/combobox.js b/lib/components/form/element/combobox.js index 43cc6efe..6843cd51 100644 --- a/lib/components/form/element/combobox.js +++ b/lib/components/form/element/combobox.js @@ -264,6 +264,14 @@ class Combobox extends React.Component { // Get the scroll position of the currently selected value this.scrollPosition = this.dropDown.scrollTop; + const stateUpdateCallback = () => { + this.resetClickAwayHandler(); + + // Fire our onChange callback + this.initialValue = selectedValue; + this.props.onChange(selectedValue); + }; + this.setState( { options: this.getTruncatedOptions(selectedValue), @@ -274,13 +282,7 @@ class Combobox extends React.Component { isDropdownOpen: false, hasError: get(currentlySelectedOption, 'hasError', false), }, - () => { - this.resetClickAwayHandler(); - - // Fire our onChange callback - this.initialValue = selectedValue; - this.props.onChange(selectedValue); - }, + stateUpdateCallback, ); } @@ -323,6 +325,34 @@ class Combobox extends React.Component { let currentValue; let currentText; + const updateStateDownKey = (state) => ({ + focusedIndex: newFocusedIndex, + options: state.options, + isDropdownOpen: true, + }); + + const updateStateUpKey = (state) => ({ + focusedIndex: newFocusedIndex, + options: state.options, + isDropdownOpen: true, + }); + + const updateStateEnterKey = (state) => ({ + options: this.getTruncatedOptions(currentValue), + selectedIndex: state.focusedIndex, + currentValue, + currentText, + isDropdownOpen: false, + }); + + const resetStateEnterKey = () => { + this.resetClickAwayHandler(); + + // Fire our onChange callback + this.props.onChange(currentValue); + this.initialValue = currentValue; + }; + // Handle the arrow keys switch (e.which) { case 40: @@ -336,14 +366,8 @@ class Combobox extends React.Component { } this.switchFocusedIndex(oldFocusedIndex, newFocusedIndex); - this.setState( - (state) => ({ - focusedIndex: newFocusedIndex, - options: state.options, - isDropdownOpen: true, - }), - this.resetClickAwayHandler, - ); + + this.setState(updateStateDownKey, this.resetClickAwayHandler); this.stopEvent(e); break; @@ -358,14 +382,7 @@ class Combobox extends React.Component { } this.switchFocusedIndex(oldFocusedIndex, newFocusedIndex); - this.setState( - (state) => ({ - focusedIndex: newFocusedIndex, - options: state.options, - isDropdownOpen: true, - }), - this.resetClickAwayHandler, - ); + this.setState(updateStateUpKey, this.resetClickAwayHandler); this.stopEvent(e); break; @@ -390,22 +407,7 @@ class Combobox extends React.Component { currentText = currentValue; } - this.setState( - (state) => ({ - options: this.getTruncatedOptions(currentValue), - selectedIndex: state.focusedIndex, - currentValue, - currentText, - isDropdownOpen: false, - }), - () => { - this.resetClickAwayHandler(); - - // Fire our onChange callback - this.props.onChange(currentValue); - this.initialValue = currentValue; - }, - ); + this.setState(updateStateEnterKey, resetStateEnterKey); this.stopEvent(e); break; @@ -443,8 +445,10 @@ class Combobox extends React.Component { const value = this.props.value || this.props.defaultValue || ''; const currentValue = this.initialValue || value; + const matchingOptionWithoutSMSDomain = (o) => (Str.isString(o) ? Str.removeSMSDomain(o.value) : o.value) === currentValue && !o.isFake; + // We use removeSMSDomain here in case currentValue is a phone number - let defaultSelectedOption = _(this.options).find((o) => (Str.isString(o) ? Str.removeSMSDomain(o.value) : o.value) === currentValue && !o.isFake); + let defaultSelectedOption = _(this.options).find(matchingOptionWithoutSMSDomain); // If no default was found and initialText was present then we can use initialText values if (!defaultSelectedOption && this.initialText) { @@ -475,32 +479,30 @@ class Combobox extends React.Component { const alreadySelected = newAlreadySelectedOptions || this.props.alreadySelectedOptions; // Get the divider index if we have one - const dividerIndex = _.findIndex(this.options, (option) => option.divider); - + const findDivider = (option) => option.divider; + const dividerIndex = _.findIndex(this.options, findDivider); // Split into two arrays everything before and after the divider (if the divider does not exist then we'll return a single array) const splitOptions = dividerIndex ? [this.options.slice(0, dividerIndex + 1), this.options.slice(dividerIndex + 1)] : [this.options]; + const formatOption = (option) => ({ + focused: false, + isSelected: option.selected && (_.isEqual(option.value, currentValue) || Boolean(_.findWhere(alreadySelected, {value: option.value}))), + ...option, + }); + + const sortByOption = (o) => { + // Unselectable text-only entries (isFake: true) go to the bottom and selected entries go to the top only if alwaysShowSelectedOnTop was passed + if (o.showLast) { + return 2; + } + + return o.isSelected && this.props.alwaysShowSelectedOnTop ? 0 : 1; + }; + // Take each array and format it, sort it, and move selected items to top (if applicable) - const truncatedOptions = _.chain(splitOptions) - .map((array) => - _.chain(array) - .map((option) => ({ - focused: false, - isSelected: option.selected && (_.isEqual(option.value, currentValue) || Boolean(_.findWhere(alreadySelected, {value: option.value}))), - ...option, - })) - .sortBy((o) => { - // Unselectable text-only entries (isFake: true) go to the bottom and selected entries go to the top only if alwaysShowSelectedOnTop was passed - if (o.showLast) { - return 2; - } - return o.isSelected && this.props.alwaysShowSelectedOnTop ? 0 : 1; - }) - .first(this.props.maxItemsToShow) - .value(), - ) - .flatten() - .value(); + const formatOptions = (array) => _.chain(array).map(formatOption).sortBy(sortByOption).first(this.props.maxItemsToShow).value(); + + const truncatedOptions = _.chain(splitOptions).map(formatOptions).flatten().value(); if (!truncatedOptions.length) { truncatedOptions.push({ @@ -543,20 +545,25 @@ class Combobox extends React.Component { const optionMatchingVal = _.findWhere(this.options, {value: val}); const currentText = get(optionMatchingVal, 'text', ''); - this.initialValue = val; - this.setState((state) => ({ + const deselectOption = (initialOption) => { + const option = initialOption; + const isSelected = _.isEqual(option.value, val); + option.isSelected = isSelected || Boolean(_.findWhere(this.props.alreadySelectedOptions, {value: option.value})); + + return option; + }; + + const deselectOptions = (options) => _(options).map(deselectOption); + + const setValueState = (state) => ({ currentValue: val, currentText, - // Deselect all other options but the one matching our value - options: _(state.options).map((o) => { - const option = o; - const isSelected = _.isEqual(option.value, val); - option.isSelected = isSelected || Boolean(_.findWhere(this.props.alreadySelectedOptions, {value: option.value})); - - return option; - }), - })); + options: deselectOptions(state.options), + }); + + this.initialValue = val; + this.setState(setValueState); } /** @@ -601,14 +608,16 @@ class Combobox extends React.Component { return; } - this.setState((state) => { + const setValueState = (state) => { const newOptions = [...state.options]; newOptions[state.selectedIndex].isSelected = false; return { options: newOptions, }; - }); + }; + + this.setState(setValueState); } /** @@ -618,7 +627,7 @@ class Combobox extends React.Component { * @param {number} newFocusedIndex */ switchFocusedIndex(oldFocusedIndex, newFocusedIndex) { - this.setState((state) => { + const setFocusedState = (state) => { const newOptions = [...state.options]; newOptions[oldFocusedIndex].focused = false; newOptions[newFocusedIndex].focused = true; @@ -626,7 +635,8 @@ class Combobox extends React.Component { return { options: newOptions, }; - }); + }; + this.setState(setFocusedState); } /** @@ -641,20 +651,27 @@ class Combobox extends React.Component { this.options = newOptions; } const state = this.getStartState(noDefaultValue, this.options, newAlreadySelectedOptions); - this.setState(state, () => this.props.onDropdownStateChange(Boolean(state.isDropdownOpen))); + const handleDropdownStateChange = () => { + this.props.onDropdownStateChange(Boolean(state.isDropdownOpen)); + }; + this.setState(state, handleDropdownStateChange); } /** * When the dropdown is closed, we reset the focused property of all of our options */ resetFocusedElements() { - this.setState((state) => ({ - options: _(state.options).map((o) => { - const option = o; - option.focused = false; - return option; - }), - })); + const resetFocusedProperty = (o) => { + const option = o; + option.focused = false; + return option; + }; + + const setValueState = (state) => ({ + options: _(state.options).map(resetFocusedProperty), + }); + + this.setState(setValueState); } /** @@ -696,16 +713,16 @@ class Combobox extends React.Component { if (this.state.isDropdownOpen) { return; } - this.setState( - { - isDropdownOpen: true, - }, - () => { - this.props.onDropdownStateChange(true); - this.resetClickAwayHandler(); - $(this.value).focus().select(); - }, - ); + const setValueState = () => ({ + isDropdownOpen: true, + }); + + const resetState = () => { + this.props.onDropdownStateChange(true); + this.resetClickAwayHandler(); + $(this.value).focus().select(); + }; + this.setState(setValueState, resetState); } /** @@ -725,15 +742,16 @@ class Combobox extends React.Component { return; } - this.setState( - { - isDropdownOpen: false, - }, - () => { - this.props.onDropdownStateChange(false); - this.resetClickAwayHandler(); - }, - ); + const setValueState = () => ({ + isDropdownOpen: false, + }); + + const resetState = () => { + this.props.onDropdownStateChange(false); + this.resetClickAwayHandler(); + }; + + this.setState(setValueState, resetState); // The value a user selects is set in state prior to this function running so we want to always treat this as if // it were just a blur event and reset the input to an empty value and then let onChange handle showing the proper value @@ -835,11 +853,13 @@ class Combobox extends React.Component { } matches = Array.from(matches); - const options = _(matches).map((option) => ({ + const formatOption = (option) => ({ focused: false, isSelected: _.isEqual(option.value ? option.value.toUpperCase : '', value.toUpperCase()) || Boolean(_.findWhere(this.props.alreadySelectedOptions, {value: option.value})), ...option, - })); + }); + + const options = _(matches).map(formatOption); // Focus the first option if there is one and show a message dependent on what options are present if (options.length) { @@ -863,8 +883,7 @@ class Combobox extends React.Component { showLast: true, }); } - - this.setState((state) => { + const setValueState = (state) => { let shouldShowDropdown = state.isDropdownOpen; // If we don't have an empty value, show the dropdown. @@ -879,7 +898,9 @@ class Combobox extends React.Component { focusedIndex: 0, options, }; - }, this.resetClickAwayHandler); + }; + + this.setState(setValueState, this.resetClickAwayHandler); } render() { diff --git a/lib/components/form/element/dropdown.js b/lib/components/form/element/dropdown.js index bc040a71..df78770e 100644 --- a/lib/components/form/element/dropdown.js +++ b/lib/components/form/element/dropdown.js @@ -49,34 +49,45 @@ const defaultProps = { }; class DropDown extends React.Component { - /** - * Handle what happens when an option is clicked on in the dropdown - * @param {Object} option - */ + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + this.renderOption = this.renderOption.bind(this); + } + handleClick(option) { - // Don't do anything if the option can't be selected if (option.isSelectable === false || option.disabled) { return; } - this.props.onChange(option.value); } - render() { + renderOption(option) { return ( -
      - {_(this.props.options).map((option) => ( - this.handleClick(option)} - /> - ))} -
    + this.handleClick(option)} + > + {option.children} + ); } + + render() { + const {options, extraClasses} = this.props; + return
      {_.map(options, this.renderOption)}
    ; + } } DropDown.propTypes = propTypes; diff --git a/lib/jquery.expensifyIframify.js b/lib/jquery.expensifyIframify.js index bcab91f9..d44c0b1e 100644 --- a/lib/jquery.expensifyIframify.js +++ b/lib/jquery.expensifyIframify.js @@ -347,10 +347,11 @@ export default { } else if (!name) { eventHandlers = {}; } else { - _.each(eventHandlers, (obj) => { + const removeEventHandler = (obj) => { const object = obj; delete object[name]; - }); + }; + _.each(eventHandlers, removeEventHandler); } }, diff --git a/lib/mixins/validationClasses.js b/lib/mixins/validationClasses.js index ff647770..37970788 100644 --- a/lib/mixins/validationClasses.js +++ b/lib/mixins/validationClasses.js @@ -14,10 +14,20 @@ export default { this.setState(this.getInitialState()); }, + /** + * Update the error state of this element + * + * @param {object} state - The current state of the component. + * @returns {object} The updated state with modified classes. + */ + handleErrorStateUpdate: (state) => ({ + classes: cn(state.classes, CONST.UI.ERROR), + }), + /** * Display the error state of this element */ showError() { - this.setState((state) => ({classes: cn(state.classes, CONST.UI.ERROR)})); + this.setState(this.handleErrorStateUpdate); }, }; diff --git a/lib/str.js b/lib/str.js index a360a51c..f9978b4e 100644 --- a/lib/str.js +++ b/lib/str.js @@ -625,7 +625,8 @@ const Str = { * @returns {String} Uppercase worded string */ ucwords(str) { - return String(str).replace(/^([a-z\u00E0-\u00FC])|\s+([a-z\u00E0-\u00FC])/g, ($1) => $1.toUpperCase()); + const capitalize = ($1) => $1.toUpperCase(); + return String(str).replace(/^([a-z\u00E0-\u00FC])|\s+([a-z\u00E0-\u00FC])/g, capitalize); }, /** @@ -1006,12 +1007,14 @@ const Str = { */ matchAll(str, regex) { const matches = []; - str.replace(regex, (...args) => { + const collectMatches = (...args) => { const match = Array.prototype.slice.call(args, 0, -2); match.input = args[args.length - 1]; match.index = args[args.length - 2]; matches.push(match); - }); + }; + + str.replace(regex, collectMatches); return matches; }, @@ -1144,4 +1147,4 @@ const Str = { }, }; -export default Str; \ No newline at end of file +export default Str; diff --git a/package-lock.json b/package-lock.json index 7c950aa4..d4ae774a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "babel-jest": "^29.0.0", "babelify": "10.0.0", "eslint": "^7.15.0", - "eslint-config-expensify": "^2.0.44", + "eslint-config-expensify": "^2.0.45", "eslint-config-prettier": "^8.10.0", "eslint-plugin-jest": "^24.7.0", "grunt": "1.6.1", @@ -4741,9 +4741,9 @@ } }, "node_modules/eslint-config-expensify": { - "version": "2.0.44", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.44.tgz", - "integrity": "sha512-fwa7lcQk7llYgqcWA1TX4kcSigYqSVkKGk+anODwYlYSbVbXwzzkQsncsaiWVTM7+eJdk46GmWPeiMAWOGWPvw==", + "version": "2.0.45", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.45.tgz", + "integrity": "sha512-WNnsXx88NBAV+hH5hRU/TGJjaLYw8VtOBHTvtOyeH3Gslj7eNT7cGCvJNZSDBOmT9dTyjlxqZtW2LSf7hWsEuw==", "dev": true, "dependencies": { "@lwc/eslint-plugin-lwc": "^1.7.2", diff --git a/package.json b/package.json index c3c94e01..b06492f1 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "babel-jest": "^29.0.0", "babelify": "10.0.0", "eslint": "^7.15.0", - "eslint-config-expensify": "^2.0.44", + "eslint-config-expensify": "^2.0.45", "eslint-config-prettier": "^8.10.0", "eslint-plugin-jest": "^24.7.0", "grunt": "1.6.1",