diff --git a/.eslintrc.js b/.eslintrc.js index f49834cc..b3a9bd5a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,6 +18,11 @@ module.exports = { 'rulesdir/no-multiple-api-calls': 'off', 'rulesdir/prefer-import-module-contents': 'off', 'no-constructor-return': 'off', + 'max-classes-per-file': 'off', + 'arrow-body-style': 'off', + 'es/no-nullish-coalescing-operators': 'off', + 'rulesdir/prefer-underscore-method': 'off', + 'es/no-optional-chaining': 'off', 'import/extensions': [ 'error', 'ignorePackages', diff --git a/__tests__/ExpensiMark-test.js b/__tests__/ExpensiMark-test.js index cad53935..fa7aa34b 100644 --- a/__tests__/ExpensiMark-test.js +++ b/__tests__/ExpensiMark-test.js @@ -1,6 +1,6 @@ /* eslint-disable max-len */ import ExpensiMark from '../lib/ExpensiMark'; -import _ from 'underscore'; +import * as Utils from '../lib/utils'; const parser = new ExpensiMark(); @@ -17,24 +17,28 @@ test('Test text is unescaped', () => { }); test('Test with regex Maximum regex stack depth reached error', () => { - const testString = '
and- // Define a named function to wrap each line with blockquote - function wrapWithBlockquote(line) { + resultString = resultString.map((line) => { return `
${line}`; - } - - // Use _.map with the named function - resultString = _.map(resultString, wrapWithBlockquote); + }); - function processString(m) { - // Recursive function to replace nested
with ">" - function replaceBlockquotes(text) { + resultString = resultString + .map((text) => { let modifiedText = text; let depth; do { @@ -464,11 +459,8 @@ export default class ExpensiMark { modifiedText = modifiedText.replace(/<\/blockquote>/gi, ''); } while (//i.test(modifiedText)); return `${'>'.repeat(depth)} ${modifiedText}`; - } - return replaceBlockquotes(m); - } - - resultString = _.map(resultString, processString).join('\n'); + }) + .join('\n'); // We want to keeptag here and let method replaceBlockElementWithNewLine to handle the line break later return `${resultString}`; @@ -621,13 +613,13 @@ export default class ExpensiMark { * @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); + this.filterRules = (rule) => !this.whitespaceRulesToDisable.includes(rule.name); /** * Filters rules to determine which should keep whitespace. * @returns {Object[]} The filtered rules. */ - this.shouldKeepWhitespaceRules = _.filter(this.rules, this.filterRules); + this.shouldKeepWhitespaceRules = this.rules.filter(this.filterRules); /** * maxQuoteDepth is the maximum depth of nested quotes that we want to support. @@ -644,16 +636,16 @@ export default class ExpensiMark { getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput) { let rules = this.rules; - const hasRuleName = (rule) => _.contains(filterRules, rule.name); - const hasDisabledRuleName = (rule) => !_.contains(disabledRules, rule.name); + const hasRuleName = (rule) => filterRules.includes(rule.name); + const hasDisabledRuleName = (rule) => !disabledRules.includes(rule.name); if (shouldKeepRawInput) { rules = this.shouldKeepWhitespaceRules; } - if (!_.isEmpty(filterRules)) { - rules = _.filter(this.rules, hasRuleName); + if (filterRules.length > 0) { + rules = this.rules.filter(hasRuleName); } - if (!_.isEmpty(disabledRules)) { - rules = _.filter(rules, hasDisabledRuleName); + if (disabledRules.length > 0) { + rules = rules.filter(hasDisabledRuleName); } return rules; } @@ -673,7 +665,7 @@ export default class ExpensiMark { */ replace(text, {filterRules = [], shouldEscapeText = true, shouldKeepRawInput = false, disabledRules = []} = {}) { // This ensures that any html the user puts into the comment field shows as raw html - let replacedText = shouldEscapeText ? _.escape(text) : text; + let replacedText = shouldEscapeText ? Utils.escape(text) : text; const rules = this.getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput); const processRule = (rule) => { @@ -700,7 +692,7 @@ export default class ExpensiMark { console.warn('Error replacing text with html in ExpensiMark.replace', {error: e}); // We want to return text without applying rules if exception occurs during replacing - return shouldEscapeText ? _.escape(text) : text; + return shouldEscapeText ? Utils.escape(text) : text; } return replacedText; @@ -870,7 +862,7 @@ export default class ExpensiMark { /|<\/div>| |\n<\/comment>|<\/comment>| |<\/h1>|
|<\/h2>|
|<\/h3>|
|<\/h4>|
|<\/h5>|
|<\/h6>|
|<\/p>|
|<\/li>| |<\/blockquote>/, ); const stripHTML = (text) => Str.stripHTML(text); - splitText = _.map(splitText, stripHTML); + splitText = splitText.map(stripHTML); let joinedText = ''; // Delete whitespace at the end @@ -924,7 +916,7 @@ 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; + const replacementFunction = Utils.isFunction(rule.replacement) ? (...args) => rule.replacement(...args, extras) : rule.replacement; generatedMarkdown = generatedMarkdown.replace(rule.regex, replacementFunction); }; @@ -944,7 +936,7 @@ export default class ExpensiMark { let replacedText = htmlString; 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; + const replacementFunction = Utils.isFunction(rule.replacement) ? (...args) => rule.replacement(...args, extras) : rule.replacement; replacedText = replacedText.replace(rule.regex, replacementFunction); }; @@ -1038,7 +1030,7 @@ export default class ExpensiMark { } return quoteContent; }; - let textToFormat = _.map(textToCheck.split('\n'), formatRow).join('\n'); + let textToFormat = textToCheck.split('\n').map(formatRow).join('\n'); // Remove leading and trailing line breaks textToFormat = textToFormat.replace(/^\n+|\n+$/g, ''); @@ -1099,7 +1091,7 @@ export default class ExpensiMark { // Element 1 from match is the regex group if it exists which contains the link URLs const sanitizeMatch = (match) => Str.sanitizeURL(match[1]); - const links = _.map(matches, sanitizeMatch); + const links = matches.map(sanitizeMatch); return links; } catch (e) { // eslint-disable-next-line no-console @@ -1118,7 +1110,7 @@ export default class ExpensiMark { getRemovedMarkdownLinks(oldComment, newComment) { const linksInOld = this.extractLinksInMarkdownComment(oldComment); const linksInNew = this.extractLinksInMarkdownComment(newComment); - return linksInOld === undefined || linksInNew === undefined ? [] : _.difference(linksInOld, linksInNew); + return linksInOld === undefined || linksInNew === undefined ? [] : linksInOld.filter((link) => !linksInNew.includes(link)); } /** @@ -1135,6 +1127,6 @@ export default class ExpensiMark { // When the attribute contains HTML and is converted back to MD we need to re-escape it to avoid // illegal attribute value characters like `," or ' which might break the HTML originalContent = Str.replaceAll(originalContent, '\n', ''); - return _.escape(originalContent); + return Utils.escape(originalContent); } } diff --git a/lib/Func.jsx b/lib/Func.jsx index 3a636b47..7d1ccefb 100644 --- a/lib/Func.jsx +++ b/lib/Func.jsx @@ -1,5 +1,4 @@ -import $ from 'jquery'; -import _ from 'underscore'; +import * as Utils from './utils'; /** * Invokes the given callback with the given arguments @@ -11,7 +10,7 @@ import _ from 'underscore'; * @returns {Mixed} */ function invoke(callback, args, scope) { - if (!_(callback).isFunction()) { + if (!Utils.isFunction(callback)) { return null; } @@ -26,18 +25,18 @@ function invoke(callback, args, scope) { * @param {Array} [args] * @param {Object} [scope] * - * @returns {$.Deferred} + * @returns {Promise} */ function invokeAsync(callback, args, scope) { - if (!_(callback).isFunction()) { - return new $.Deferred().resolve(); + if (!Utils.isFunction(callback)) { + return Promise.resolve(); } let promiseFromCallback = callback.apply(scope, args || []); // If there was not a promise returned from the prefetch callback, then create a dummy promise and resolve it if (!promiseFromCallback) { - promiseFromCallback = new $.Deferred().resolve(); + promiseFromCallback = Promise.resolve(); } return promiseFromCallback; @@ -68,7 +67,11 @@ function die() { * @returns {Array} */ function mapByName(list, methodName) { - return _.map(list, (item) => item[methodName].call(item)); + let arr = list; + if (!Array.isArray(arr)) { + arr = Object.values(arr); + } + return arr.map((item) => item[methodName].call(item)); } export {invoke, invokeAsync, bulkInvoke, die, mapByName}; diff --git a/lib/Log.jsx b/lib/Log.jsx index bd55a1f9..3c90d904 100644 --- a/lib/Log.jsx +++ b/lib/Log.jsx @@ -1,9 +1,8 @@ /* eslint-disable no-console */ -import _ from 'underscore'; import API from './API'; import Network from './Network'; import Logger from './Logger'; -import {isWindowAvailable} from './utils'; +import * as Utils from './utils'; /** * Network interface for logger. @@ -24,11 +23,11 @@ function serverLoggingCallback(logger, params) { * @param {String} message */ function clientLoggingCallback(message) { - if (isWindowAvailable() && typeof window.g_printableReport !== 'undefined' && window.g_printableReport === true) { + if (Utils.isWindowAvailable() && typeof window.g_printableReport !== 'undefined' && window.g_printableReport === true) { return; } - if (window.console && _.isFunction(console.log)) { + if (window.console && Utils.isFunction(console.log)) { console.log(message); } } @@ -36,5 +35,5 @@ function clientLoggingCallback(message) { export default new Logger({ serverLoggingCallback, clientLoggingCallback, - isDebug: isWindowAvailable() ? window.DEBUG : false, + isDebug: Utils.isWindowAvailable() ? window.DEBUG : false, }); diff --git a/lib/Logger.ts b/lib/Logger.ts index 90d70b9a..61756cf3 100644 --- a/lib/Logger.ts +++ b/lib/Logger.ts @@ -53,9 +53,10 @@ export default class Logger { } // eslint-disable-next-line rulesdir/prefer-early-return promise.then((response) => { - if (response.requestID) { - this.info('Previous log requestID', false, {requestID: response.requestID}, true); + if (!response.requestID) { + return; } + this.info('Previous log requestID', false, {requestID: response.requestID}, true); }); } diff --git a/lib/Network.jsx b/lib/Network.jsx index 85c6e432..7d109a2f 100644 --- a/lib/Network.jsx +++ b/lib/Network.jsx @@ -1,6 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; -import {isWindowAvailable} from './utils'; +import * as Utils from './utils'; /** * Adds our API command to the URL so the API call is more easily identified in the @@ -41,7 +40,7 @@ export default function Network(endpoint) { } // Attach a listener to the event indicating that we're leaving a page - if (isWindowAvailable()) { + if (Utils.isWindowAvailable()) { window.onbeforeunload = () => { isNavigatingAway = true; }; @@ -87,7 +86,7 @@ export default function Network(endpoint) { // Check to see if parameters contains a File or Blob object // If it does, we should use formData instead of parameters and update // the ajax settings accordingly - _(parameters).each((value, key) => { + Object.entries(parameters).forEach(([key, value]) => { if (!value) { return; } @@ -133,14 +132,14 @@ export default function Network(endpoint) { // Add our data as form data const formData = new FormData(); - _(parameters).each((value, key) => { - if (_.isUndefined(value)) { + Object.entries(parameters).forEach(([key, value]) => { + if (value === undefined) { return; } - if (_.isArray(value)) { - _.each(value, (valueItem, i) => { - if (_.isObject(valueItem)) { - _.each(valueItem, (valueItemObjectValue, valueItemObjectKey) => { + if (Array.isArray(value)) { + value.forEach((valueItem, i) => { + if (Utils.isObject(valueItem)) { + Object.entries(valueItem).forEach(([valueItemObjectKey, valueItemObjectValue]) => { formData.append(`${key}[${i}][${valueItemObjectKey}]`, valueItemObjectValue); }); } else { diff --git a/lib/Num.jsx b/lib/Num.jsx index 82b864d9..80d83c1c 100644 --- a/lib/Num.jsx +++ b/lib/Num.jsx @@ -1,4 +1,3 @@ -import _ from 'underscore'; import Str from './str'; export default { @@ -123,7 +122,7 @@ export default { * @returns {Boolean} true if the number is finite and not NaN. */ isFiniteNumber(number) { - return _.isNumber(number) && _.isFinite(number) && !_.isNaN(number); + return typeof number === 'number' && Number.isFinite(number) && !Number.isNaN(number); }, /** diff --git a/lib/PubSub.jsx b/lib/PubSub.jsx index 28fc6ea7..aa0df607 100644 --- a/lib/PubSub.jsx +++ b/lib/PubSub.jsx @@ -1,7 +1,7 @@ -import _ from 'underscore'; import has from 'lodash/has'; +import {once} from 'lodash'; import Log from './Log'; -import {isWindowAvailable} from './utils'; +import * as Utils from './utils'; /** * PubSub @@ -46,14 +46,14 @@ const PubSubModule = { return; } - const eventIDs = _.keys(eventMap[eventName]); + const eventIDs = eventMap[eventName].keys(); if (eventName === this.ERROR) { // Doing the split slice 2 because the 1st element of the stacktrace will always be from here (PubSub.publish) // When debugging, we just need to know who called PubSub.publish (so, all next elements in the stack) Log.hmmm('Error published', 0, {tplt: param.tplt, stackTrace: new Error().stack.split(' at ').slice(2)}); } - _.each(eventIDs, (eventID) => { + eventIDs.forEach((eventID) => { const subscriber = eventMap[eventName][eventID]; if (subscriber) { subscriber.callback.call(subscriber.scope, param); @@ -72,8 +72,8 @@ const PubSubModule = { * @returns {String} */ once(eventName, callback, optionalScope) { - const scope = _.isObject(optionalScope) ? optionalScope : window; - const functionToCallOnce = _.once((...args) => callback.apply(scope, args)); + const scope = Utils.isObject(optionalScope) && optionalScope !== null ? optionalScope : window; + const functionToCallOnce = once((...args) => callback.apply(scope, args)); return this.subscribe(eventName, functionToCallOnce); }, @@ -94,8 +94,8 @@ const PubSubModule = { throw new Error('Attempted to subscribe to undefined event'); } - const callback = _.isFunction(optionalCallback) ? optionalCallback : () => {}; - const scope = _.isObject(optionalScope) ? optionalScope : window; + const callback = Utils.isFunction(optionalCallback) ? optionalCallback : () => {}; + const scope = Utils.isObject(optionalScope) && optionalScope !== null ? optionalScope : window; const eventID = generateID(eventName); if (eventMap[eventName] === undefined) { @@ -116,8 +116,8 @@ const PubSubModule = { * @param {String} bindID The id of the element to delete */ unsubscribe(bindID) { - const IDs = _.isArray(bindID) ? bindID : [bindID]; - _.each(IDs, (id) => { + const IDs = Array.isArray(bindID) ? bindID : [bindID]; + IDs.forEach((id) => { const eventName = extractEventName(id); if (has(eventMap, `${eventName}.${id}`)) { delete eventMap[eventName][id]; @@ -126,4 +126,4 @@ const PubSubModule = { }, }; -export default isWindowAvailable() && window.PubSub ? window.PubSub : PubSubModule; +export default Utils.isWindowAvailable() && window.PubSub ? window.PubSub : PubSubModule; diff --git a/lib/ReportHistoryStore.jsx b/lib/ReportHistoryStore.jsx index c8acae97..b92f694b 100644 --- a/lib/ReportHistoryStore.jsx +++ b/lib/ReportHistoryStore.jsx @@ -1,4 +1,3 @@ -import _ from 'underscore'; import {Deferred} from 'simply-deferred'; export default class ReportHistoryStore { @@ -31,11 +30,12 @@ export default class ReportHistoryStore { * * @returns {Object[]} */ - this.filterHiddenActions = (historyItems) => _.filter(historyItems, (historyItem) => historyItem.shouldShow); + this.filterHiddenActions = (historyItems) => historyItems.filter((historyItem) => historyItem.shouldShow); /** * Public Methods */ + return { /** * Returns the history for a given report. @@ -124,7 +124,7 @@ export default class ReportHistoryStore { this.getFromCache(reportID) .done((cachedHistory) => { // Do we have the reportAction immediately before this one? - if (_.some(cachedHistory, ({reportActionID}) => reportActionID === reportAction.reportActionID)) { + if (cachedHistory.some(({reportActionID}) => reportActionID === reportAction.reportActionID)) { // If we have the previous one then we can assume we have an up to date history minus the most recent // and must merge it into the cache this.mergeHistoryByTimestamp(reportID, [reportAction]); @@ -149,7 +149,7 @@ export default class ReportHistoryStore { * @param {String[]} events */ bindCacheClearingEvents: (events) => { - _.each(events, (event) => this.PubSub.subscribe(event, () => (this.cache = {}))); + events.each((event) => this.PubSub.subscribe(event, () => (this.cache = {}))); }, // We need this to be publically available for cases where we get the report history @@ -169,19 +169,15 @@ export default class ReportHistoryStore { return; } - const newCache = _.reduce( - newHistory.reverse(), - (prev, curr) => { - if (!_.findWhere(prev, {sequenceNumber: curr.sequenceNumber})) { - prev.unshift(curr); - } - return prev; - }, - this.cache[reportID] || [], - ); + const newCache = newHistory.reverse().reduce((prev, curr) => { + if (!prev.some((item) => item.sequenceNumber === curr.sequenceNumber)) { + prev.unshift(curr); + } + return prev; + }, this.cache[reportID] || []); // Sort items in case they have become out of sync - this.cache[reportID] = _.sortBy(newCache, 'sequenceNumber').reverse(); + this.cache[reportID] = newCache.sort((a, b) => b.sequenceNumber - a.sequenceNumber); } /** @@ -195,19 +191,15 @@ export default class ReportHistoryStore { return; } - const newCache = _.reduce( - newHistory.reverse(), - (prev, curr) => { - if (!_.findWhere(prev, {reportActionTimestamp: curr.reportActionTimestamp})) { - prev.unshift(curr); - } - return prev; - }, - this.cache[reportID] || [], - ); + const newCache = newHistory.reverse().reduce((prev, curr) => { + if (!prev.some((item) => item.reportActionTimestamp === curr.reportActionTimestamp)) { + prev.unshift(curr); + } + return prev; + }, this.cache[reportID] || []); // Sort items in case they have become out of sync - this.cache[reportID] = _.sortBy(newCache, 'reportActionTimestamp').reverse(); + this.cache[reportID] = newCache.sort((a, b) => b.reportActionTimestamp - a.reportActionTimestamp); } /** @@ -228,7 +220,7 @@ export default class ReportHistoryStore { // We'll poll the API for the un-cached history const cachedHistory = this.cache[reportID] || []; - const firstHistoryItem = _.first(cachedHistory) || {}; + const firstHistoryItem = cachedHistory[0] || {}; // Grab the most recent sequenceNumber we have and poll the API for fresh data this.API.Report_GetHistory({ @@ -290,7 +282,7 @@ export default class ReportHistoryStore { const cachedHistory = this.cache[reportID] || []; // If comment is not in cache then fetch it - if (_.isEmpty(cachedHistory)) { + if (cachedHistory.length === 0) { return this.getFlatHistory(reportID); } diff --git a/lib/Templates.jsx b/lib/Templates.jsx index e5e21fe8..17be9b2f 100644 --- a/lib/Templates.jsx +++ b/lib/Templates.jsx @@ -1,6 +1,6 @@ -/* eslint-disable max-classes-per-file */ -import _ from 'underscore'; import $ from 'jquery'; +import {template as createTemplate} from 'lodash'; +import * as Utils from './utils'; /** * JS Templating system, powered by underscore template @@ -37,7 +37,7 @@ export default (function () { */ get(data = {}) { if (!this.compiled) { - this.compiled = _.template(this.templateValue); + this.compiled = createTemplate(this.templateValue); this.templateValue = ''; } return this.compiled(data); @@ -71,7 +71,7 @@ export default (function () { // eslint-disable-next-line no-undef dataToCompile.nestedTemplate = Templates.get; if (!this.compiled) { - this.compiled = _.template($(`#${this.id}`).html()); + this.compiled = createTemplate($(`#${this.id}`).html()); } return this.compiled(dataToCompile); } @@ -85,7 +85,7 @@ export default (function () { */ function getTemplate(templatePath) { let template = templateStore; - _.each(templatePath, (pathname) => { + templatePath.forEach((pathname) => { template = template[pathname]; }); return template; @@ -104,7 +104,7 @@ export default (function () { for (let argumentNumber = 0; argumentNumber < wantedNamespace.length; argumentNumber++) { currentArgument = wantedNamespace[argumentNumber]; - if (_.isUndefined(namespace[currentArgument])) { + if (namespace[currentArgument] === undefined) { namespace[currentArgument] = {}; } namespace = namespace[currentArgument]; @@ -122,7 +122,7 @@ export default (function () { */ get(templatePath, data = {}) { const template = getTemplate(templatePath); - if (_.isUndefined(template)) { + if (template === undefined) { throw Error(`Template '${templatePath}' is not defined`); } @@ -141,7 +141,7 @@ export default (function () { * @return {Boolean} */ has(templatePath) { - return !_.isUndefined(getTemplate(templatePath)); + return getTemplate(templatePath) !== undefined; }, /** @@ -169,13 +169,13 @@ export default (function () { */ register(wantedNamespace, templateData) { const namespace = getTemplateNamespace(wantedNamespace); - _.each(_.keys(templateData), (key) => { + Object.keys(templateData).forEach((key) => { const template = templateData[key]; - if (_.isObject(template)) { + if (Utils.isObject(template)) { // If the template is an object, add templates for all keys namespace[key] = {}; - _.each(_.keys(template), (templateKey) => { + Object.keys(template).forEach((templateKey) => { namespace[key][templateKey] = new InlineTemplate(template[templateKey]); }); } else { diff --git a/lib/components/StepProgressBar.js b/lib/components/StepProgressBar.js index d682ef4a..f6564a88 100644 --- a/lib/components/StepProgressBar.js +++ b/lib/components/StepProgressBar.js @@ -1,5 +1,4 @@ import React from 'react'; -import _ from 'underscore'; import PropTypes from 'prop-types'; import cn from 'classnames'; import * as UIConstants from '../CONST'; @@ -24,7 +23,7 @@ const propTypes = { */ function StepProgressBar({steps, currentStep}) { const isCurrentStep = (step) => step.id === currentStep; - const currentStepIndex = Math.max(0, _.findIndex(steps, isCurrentStep)); + const currentStepIndex = Math.max(0, steps.findIndex(isCurrentStep)); const renderStep = (step, i) => { let status = currentStepIndex === i ? UIConstants.UI.ACTIVE : ''; @@ -52,7 +51,7 @@ function StepProgressBar({steps, currentStep}) { id="js_steps_progress" className="progress-wrapper" > -{_.map(steps, renderStep)}+{steps.map(renderStep)}); } diff --git a/lib/components/form/element/combobox.js b/lib/components/form/element/combobox.js index 6843cd51..53a6e10b 100644 --- a/lib/components/form/element/combobox.js +++ b/lib/components/form/element/combobox.js @@ -2,12 +2,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import cn from 'classnames'; -import _ from 'underscore'; -import get from 'lodash/get'; -import has from 'lodash/has'; -import uniqBy from 'lodash/uniqBy'; +import {defer, has, isEqual, template, uniqBy} from 'lodash'; import Str from '../../../str'; import DropDown from './dropdown'; +import * as Utils from '../../../utils'; const propTypes = { // These are the elements to show in the dropdown @@ -54,6 +52,7 @@ const propTypes = { // that are already selected with the check mark. alreadySelectedOptions: PropTypes.arrayOf( PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), text: PropTypes.string, }), @@ -186,39 +185,39 @@ class Combobox extends React.Component { } // Our dropdown will be open, so we need to listen for our click away events // and put focus on the input - _.defer(this.resetClickAwayHandler); + defer(this.resetClickAwayHandler); $(this.value).focus().select(); } // eslint-disable-next-line react/no-unsafe UNSAFE_componentWillReceiveProps(nextProps) { - if (_.isUndefined(nextProps.value) || _.isEmpty(nextProps.value) || _.isEqual(nextProps.value, this.state.currentValue)) { + if (!nextProps.value || isEqual(nextProps.value, this.state.currentValue)) { return; } this.setValue(nextProps.value); - if (!_.isUndefined(nextProps.options)) { + if (nextProps.options !== undefined) { // If the options have an id property, we use that to compare them and determine if they changed, if not // we'll use the whole options array. if (has(nextProps.options, '0.id')) { if ( - !_.isEqual(_.pluck(nextProps.options, 'id'), _.pluck(this.props.options, 'id')) || - !_.isEqual(_.pluck(nextProps.alreadySelectedOptions, 'id'), _.pluck(this.props.alreadySelectedOptions, 'id')) + nextProps.options.some((option, index) => !isEqual(option.id, this.props.options[index].id)) || + nextProps.alreadySelectedOptions.some((alreadySelectedOption, index) => !isEqual(alreadySelectedOption.id, this.props.alreadySelectedOptions[index].id)) ) { this.reset(false, nextProps.options, nextProps.alreadySelectedOptions); } - } else if (!_.isEqual(nextProps.options, this.props.options) || !_.isEqual(nextProps.alreadySelectedOptions, this.props.alreadySelectedOptions)) { + } else if (!isEqual(nextProps.options, this.props.options) || !isEqual(nextProps.alreadySelectedOptions, this.props.alreadySelectedOptions)) { this.reset(false, nextProps.options, nextProps.alreadySelectedOptions); } } - if (!_.isUndefined(nextProps.openOnInit) && !_.isEqual(nextProps.openOnInit, this.props.openOnInit)) { + if (nextProps.openOnInit !== undefined && !isEqual(nextProps.openOnInit, this.props.openOnInit)) { this.setState({ isDropdownOpen: nextProps.openOnInit, }); } - if (!_.isUndefined(nextProps.isReadOnly) && !_.isEqual(nextProps.isReadOnly, this.props.isReadOnly)) { + if (nextProps.isReadOnly !== undefined && !isEqual(nextProps.isReadOnly, this.props.isReadOnly)) { this.setState({ isDisabled: nextProps.isReadOnly, }); @@ -249,8 +248,8 @@ class Combobox extends React.Component { // Select the new item, set our new indexes, close the dropdown // Unselect all other options - let newSelectedIndex = _(this.options).findIndex({value: selectedValue}); - let currentlySelectedOption = _(this.options).findWhere({value: selectedValue}); + let newSelectedIndex = this.options.findIndex({value: selectedValue}); + let currentlySelectedOption = this.options.findWhere({value: selectedValue}); // If allowAnyValue is true and currentValue is absent then set it manually to what the user has entered. if (newSelectedIndex === -1 && this.props.allowAnyValue && selectedValue) { @@ -278,9 +277,9 @@ class Combobox extends React.Component { selectedIndex: newSelectedIndex, focusedIndex: newSelectedIndex, currentValue: selectedValue, - currentText: get(currentlySelectedOption, 'text', ''), + currentText: (currentlySelectedOption && currentlySelectedOption.text) || '', isDropdownOpen: false, - hasError: get(currentlySelectedOption, 'hasError', false), + hasError: (currentlySelectedOption && currentlySelectedOption.hasError) || false, }, stateUpdateCallback, ); @@ -448,7 +447,7 @@ class Combobox extends React.Component { 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(matchingOptionWithoutSMSDomain); + 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) { @@ -480,13 +479,13 @@ class Combobox extends React.Component { // Get the divider index if we have one const findDivider = (option) => option.divider; - const dividerIndex = _.findIndex(this.options, findDivider); + const dividerIndex = this.options.findIndex(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}))), + isSelected: option.selected && (isEqual(option.value, currentValue) || Boolean(alreadySelected.find((item) => item.value === option.value))), ...option, }); @@ -500,9 +499,13 @@ class Combobox extends React.Component { }; // Take each array and format it, sort it, and move selected items to top (if applicable) - const formatOptions = (array) => _.chain(array).map(formatOption).sortBy(sortByOption).first(this.props.maxItemsToShow).value(); + const formatOptions = (array) => + array + .map(formatOption) + .sort((a, b) => sortByOption(a) - sortByOption(b)) + .slice(0, this.props.maxItemsToShow); - const truncatedOptions = _.chain(splitOptions).map(formatOptions).flatten().value(); + const truncatedOptions = splitOptions.map(formatOptions).flat(); if (!truncatedOptions.length) { truncatedOptions.push({ @@ -542,18 +545,18 @@ class Combobox extends React.Component { setValue(val) { // We need to look in `this.options` for the matching option because `this.state.options` is a truncated list // and might not have every option - const optionMatchingVal = _.findWhere(this.options, {value: val}); - const currentText = get(optionMatchingVal, 'text', ''); + const optionMatchingVal = this.options.find((option) => option.value === val); + const currentText = optionMatchingVal?.text || ''; const deselectOption = (initialOption) => { const option = initialOption; - const isSelected = _.isEqual(option.value, val); - option.isSelected = isSelected || Boolean(_.findWhere(this.props.alreadySelectedOptions, {value: option.value})); + const isSelected = isEqual(option.value, val); + option.isSelected = isSelected || Boolean(this.props.alreadySelectedOptions.find((optionItem) => optionItem.value === option.value)); return option; }; - const deselectOptions = (options) => _(options).map(deselectOption); + const deselectOptions = (options) => options.map(deselectOption); const setValueState = (state) => ({ currentValue: val, @@ -575,8 +578,8 @@ class Combobox extends React.Component { // See if there is a value in the options that matches the text we want to set. If the option // does exist, then use the text property of that option for the text to display. If the option // does not exist, then just display whatever value was passed - const optionMatchingVal = _.findWhere(this.options, {value: val}); - const currentText = get(optionMatchingVal, 'text', val); + const optionMatchingVal = this.options.find((option) => option.value === val); + const currentText = (optionMatchingVal && optionMatchingVal.text) || val; this.initialValue = currentText; this.initialText = currentText; this.setState({currentText}); @@ -668,7 +671,7 @@ class Combobox extends React.Component { }; const setValueState = (state) => ({ - options: _(state.options).map(resetFocusedProperty), + options: state.options.map(resetFocusedProperty), }); this.setState(setValueState); @@ -855,11 +858,13 @@ class Combobox extends React.Component { const formatOption = (option) => ({ focused: false, - isSelected: _.isEqual(option.value ? option.value.toUpperCase : '', value.toUpperCase()) || Boolean(_.findWhere(this.props.alreadySelectedOptions, {value: option.value})), + isSelected: + isEqual(option.value ? option.value.toUpperCase : '', value.toUpperCase()) || + Boolean(this.props.alreadySelectedOptions.find((optionItem) => optionItem.value === option.value)), ...option, }); - const options = _(matches).map(formatOption); + 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) { @@ -876,7 +881,7 @@ class Combobox extends React.Component { } } else { options.push({ - text: this.props.allowAnyValue ? value : _.template(this.props.noResultsText)({value}), + text: this.props.allowAnyValue ? value : template(this.props.noResultsText)({value}), value: this.props.allowAnyValue ? value : '', isSelectable: this.props.allowAnyValue, isFake: true, @@ -933,7 +938,7 @@ class Combobox extends React.Component { aria-label="..." onChange={this.performSearch} onKeyDown={this.closeDropdownOnTabOut} - value={this.props.propertyToDisplay === 'value' ? _.unescape(this.state.currentValue) : _.unescape(this.state.currentText.replace(/ /g, ''))} + value={this.props.propertyToDisplay === 'value' ? Utils.unescape(this.state.currentValue) : Utils.unescape(this.state.currentText.replace(/ /g, ''))} onFocus={this.openDropdown} autoComplete="off" placeholder={this.props.placeholder} diff --git a/lib/components/form/element/dropdown.js b/lib/components/form/element/dropdown.js index df78770e..2b1d7d45 100644 --- a/lib/components/form/element/dropdown.js +++ b/lib/components/form/element/dropdown.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import cn from 'classnames'; -import _ from 'underscore'; +import {uniqueId} from 'lodash'; import DropDownItem from './dropdownItem'; const propTypes = { @@ -65,7 +65,7 @@ class DropDown extends React.Component { renderOption(option) { return ({_.map(options, this.renderOption)}; + return {options.map(this.renderOption)}
; } } diff --git a/lib/components/form/element/switch.js b/lib/components/form/element/switch.js index 8ad6807b..96447352 100644 --- a/lib/components/form/element/switch.js +++ b/lib/components/form/element/switch.js @@ -3,7 +3,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import cn from 'classnames'; -import _ from 'underscore'; /** * Form Element Switch - Displays an on/off switch @@ -95,18 +94,14 @@ class Switch extends React.Component { } e.preventDefault(); - Modal.confirm( - _.extend( - { - onYesCallback: () => { - // Toggle the checked property and then fire our change handler - this.checkbox.checked = !this.getValue(); - Func.invoke(this.props.onChange, [this.getValue()]); - }, - }, - this.props.confirm, - ), - ); + Modal.confirm({ + ...(this.props.confirm ?? {}), + onYesCallback: () => { + // Toggle the checked property and then fire our change handler + this.checkbox.checked = !this.getValue(); + Func.invoke(this.props.onChange, [this.getValue()]); + }, + }); return false; } diff --git a/lib/mixins/PubSub.jsx b/lib/mixins/PubSub.jsx index 8be94395..b5552759 100644 --- a/lib/mixins/PubSub.jsx +++ b/lib/mixins/PubSub.jsx @@ -1,8 +1,7 @@ -import _ from 'underscore'; import PubSubModule from '../PubSub'; -import {isWindowAvailable} from '../utils'; +import * as Utils from '../utils'; -const PubSub = (isWindowAvailable() && window.PubSub) || PubSubModule; +const PubSub = (Utils.isWindowAvailable() && window.PubSub) || PubSubModule; /** * This mixin sets up automatic PubSub bindings which will be removed when @@ -18,7 +17,7 @@ const PubSub = (isWindowAvailable() && window.PubSub) || PubSubModule; * } * }); */ -export default { +const PubSubMixin = { UNSAFE_componentWillMount() { this.eventIds = []; }, @@ -45,6 +44,10 @@ export default { * When the component is unmounted, we want to subscribe from all of our event IDs */ componentWillUnmount() { - _.each(this.eventIds, _.bind(PubSub.unsubscribe, PubSub)); + this.eventIds.forEach((eventId) => { + PubSub.unsubscribe(eventId); + }); }, }; + +export default PubSubMixin; diff --git a/lib/mixins/extraClasses.js b/lib/mixins/extraClasses.js index 13acbfb1..a0cf9b9e 100644 --- a/lib/mixins/extraClasses.js +++ b/lib/mixins/extraClasses.js @@ -23,11 +23,11 @@ * } */ -import {isWindowAvailable} from '../utils'; +import * as Utils from '../utils'; export default { propTypes: { - extraClasses: isWindowAvailable() && window.PropTypes.oneOfType([window.PropTypes.string, window.PropTypes.array, window.PropTypes.object]), + extraClasses: Utils.isWindowAvailable() && window.PropTypes.oneOfType([window.PropTypes.string, window.PropTypes.array, window.PropTypes.object]), }, UNSAFE_componentWillReceiveProps(nextProps) { diff --git a/lib/str.ts b/lib/str.ts index 0e9da4a4..210a72f5 100644 --- a/lib/str.ts +++ b/lib/str.ts @@ -1,10 +1,9 @@ /* eslint-disable no-control-regex */ -import lodashEscape from 'lodash/escape'; -import lodashUnescape from 'lodash/unescape'; import {parsePhoneNumber} from 'awesome-phonenumber'; import * as HtmlEntities from 'html-entities'; import * as Constants from './CONST'; import * as UrlPatterns from './Url'; +import * as Utils from './utils'; const REMOVE_SMS_DOMAIN_PATTERN = /@expensify\.sms/gi; @@ -87,11 +86,7 @@ const Str = { * @param s The string to decode. * @returns The decoded string. */ - htmlDecode(s: string): string { - // Use jQuery if it exists or else use html-entities - if (typeof jQuery !== 'undefined') { - return jQuery('').html(s).text(); - } + htmlDecode(s: string) { return HtmlEntities.decode(s); }, @@ -101,11 +96,7 @@ const Str = { * @param s The string to encode. * @return string @p s HTML encoded. */ - htmlEncode(s: string): string { - // Use jQuery if it exists or else use html-entities - if (typeof jQuery !== 'undefined') { - return jQuery('').text(s).html(); - } + htmlEncode(s: string) { return HtmlEntities.encode(s); }, @@ -115,8 +106,8 @@ const Str = { * @param s The string to escape * @returns The escaped string */ - safeEscape(s: string): string { - return lodashEscape(lodashUnescape(s)); + safeEscape(s: string) { + return Utils.escape(Utils.unescape(s)); }, /** @@ -763,8 +754,8 @@ const Str = { /** * Trim a string */ - trim(str: string): string { - return $.trim(str); + trim(str: string) { + return str.trim(); }, /** diff --git a/lib/utils.ts b/lib/utils.ts index 05f79512..d916a695 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -8,4 +8,66 @@ function isNavigatorAvailable(): boolean { return typeof navigator !== 'undefined'; } -export {isWindowAvailable, isNavigatorAvailable}; +const htmlEscapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '`': '`', +}; +const reUnescapedHtml = /[&<>"'`]/g; +const reHasUnescapedHtml = RegExp(reUnescapedHtml.source); + +/** + * Converts the characters "&", "<", ">", '"', and "'" in `string` to their + * corresponding HTML entities. + * Source: https://github.com/lodash/lodash/blob/main/src/escape.ts + */ +function escape(string: string): string { + return string && reHasUnescapedHtml.test(string) ? string.replace(reUnescapedHtml, (chr) => htmlEscapes[chr as keyof typeof htmlEscapes]) : string || ''; +} + +const htmlUnescapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + '`': '`', + ' ': ' ', +}; + +const reEscapedHtml = /&(?:amp|lt|gt|quot|#(x27|x60|32));/g; +const reHasEscapedHtml = RegExp(reEscapedHtml.source); + +/** + * The inverse of `escape`this method converts the HTML entities + * `&`, `<`, `>`, `"` and `'` in `string` to + * their corresponding characters. + * Source: https://github.com/lodash/lodash/blob/main/src/unescape.ts + * */ +function unescape(string: string): string { + return string && reHasEscapedHtml.test(string) ? string.replace(reEscapedHtml, (entity) => htmlUnescapes[entity as keyof typeof htmlUnescapes] || "'") : string || ''; +} + +/** + * Checks if the given variable is a function + * @param {*} variableToCheck + * @returns {boolean} + */ +function isFunction(variableToCheck: unknown): boolean { + return variableToCheck instanceof Function; +} + +/** + * Checks if the given variable is an object + * @param {*} obj + * @returns {boolean} + */ +function isObject(obj: unknown): boolean { + const type = typeof obj; + return type === 'function' || (!!obj && type === 'object'); +} + +export {isWindowAvailable, isNavigatorAvailable, escape, unescape, isFunction, isObject}; diff --git a/package-lock.json b/package-lock.json index e43b4dde..900de026 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,7 @@ "react-dom": "16.12.0", "semver": "^7.6.0", "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", - "ua-parser-js": "^1.0.37", - "underscore": "1.13.6" + "ua-parser-js": "^1.0.37" }, "devDependencies": { "@babel/preset-env": "^7.24.4", @@ -12578,7 +12577,8 @@ "node_modules/underscore": { "version": "1.13.6", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", - "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==" + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "dev": true }, "node_modules/underscore.string": { "version": "3.3.6", diff --git a/package.json b/package.json index af18e40c..7f4efbf0 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,7 @@ "react-dom": "16.12.0", "semver": "^7.6.0", "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", - "ua-parser-js": "^1.0.37", - "underscore": "1.13.6" + "ua-parser-js": "^1.0.37" }, "devDependencies": { "@babel/preset-env": "^7.24.4",