Repository: openedx/frontend-app-authoring
Pull request #1772: feat: lighter build by rewriting lodash imports
Author: login: regisb
Labels: name: open-source-contribution
description: PR author is not from Axim or 2U
Created: 2025-04-01T12:14:53Z | Merged: 2025-04-16T00:07:16Z

Incorrect lodash imports are causing MFEs to import the entire lodash library. This change shaves off a few kB of every MFE compressed build.


Diff:
diff --git a/src/course-libraries/CourseLibraries.tsx b/src/course-libraries/CourseLibraries.tsx
index fcdea5c98a..3c1a0730fe 100644
--- a/src/course-libraries/CourseLibraries.tsx
+++ b/src/course-libraries/CourseLibraries.tsx
@@ -20,7 +20,7 @@ import {
   Cached, CheckCircle, Launch, Loop,
 } from '@openedx/paragon/icons';
 
-import _ from 'lodash';
+import sumBy from 'lodash/sumBy';
 import { useSearchParams } from 'react-router-dom';
 import getPageHeadTitle from '../generic/utils';
 import { useModel } from '../generic/model-store';
@@ -109,7 +109,7 @@ export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
   );
   const [showReviewAlert, setShowReviewAlert] = useState(false);
   const { data: libraries, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
-  const outOfSyncCount = useMemo(() => _.sumBy(libraries, (lib) => lib.readyToSyncCount), [libraries]);
+  const outOfSyncCount = useMemo(() => sumBy(libraries, (lib) => lib.readyToSyncCount), [libraries]);
   const {
     isLoadingPage: isLoadingStudioHome,
     isFailedLoadingPage: isFailedLoadingStudioHome,
diff --git a/src/course-libraries/ReviewTabContent.tsx b/src/course-libraries/ReviewTabContent.tsx
index 80a9c12c3b..1c756c5ca9 100644
--- a/src/course-libraries/ReviewTabContent.tsx
+++ b/src/course-libraries/ReviewTabContent.tsx
@@ -14,7 +14,9 @@ import {
   useToggle,
 } from '@openedx/paragon';
 
-import _ from 'lodash';
+import {
+  tail, keyBy, orderBy, merge, omitBy,
+} from 'lodash';
 import { useQueryClient } from '@tanstack/react-query';
 import { Loop, Warning } from '@openedx/paragon/icons';
 import messages from './messages';
@@ -49,7 +51,7 @@ interface BlockCardProps {
 const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
   const intl = useIntl();
   const componentIcon = getItemIcon(info.blockType);
-  const breadcrumbs = _.tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>;
+  const breadcrumbs = tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>;
 
   const getBlockLink = useCallback(() => {
     let key = info.usageKey;
@@ -138,11 +140,11 @@ const ComponentReviewList = ({
   );
 
   const outOfSyncComponentsByKey = useMemo(
-    () => _.keyBy(outOfSyncComponents, 'downstreamUsageKey'),
+    () => keyBy(outOfSyncComponents, 'downstreamUsageKey'),
     [outOfSyncComponents],
   );
   const downstreamInfoByKey = useMemo(
-    () => _.keyBy(downstreamInfo, 'usageKey'),
+    () => keyBy(downstreamInfo, 'usageKey'),
     [downstreamInfo],
   );
   const queryClient = useQueryClient();
@@ -241,9 +243,9 @@ const ComponentReviewList = ({
     if (isIndexDataLoading) {
       return [];
     }
-    let merged = _.merge(downstreamInfoByKey, outOfSyncComponentsByKey);
-    merged = _.omitBy(merged, (o) => !o.displayName);
-    const ordered = _.orderBy(Object.values(merged), 'updated', 'desc');
+    let merged = merge(downstreamInfoByKey, outOfSyncComponentsByKey);
+    merged = omitBy(merged, (o) => !o.displayName);
+    const ordered = orderBy(Object.values(merged), 'updated', 'desc');
     return ordered;
   }, [downstreamInfoByKey, outOfSyncComponentsByKey]);
 
diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js
index 5f10f22894..dffc721af5 100644
--- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js
+++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/hooks.js
@@ -1,6 +1,8 @@
 import { useState, useEffect } from 'react';
 
-import _ from 'lodash';
+import {
+  includes, isEmpty, isFinite, isNaN, isNil,
+} from 'lodash';
 // This 'module' self-import hack enables mocking during tests.
 // See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
 // should be re-thought and cleaned up to avoid this pattern.
@@ -65,7 +67,7 @@ export const hintsCardHooks = (hints, updateSettings) => {
 
   const handleAdd = () => {
     let newId = 0;
-    if (!_.isEmpty(hints)) {
+    if (!isEmpty(hints)) {
       newId = Math.max(...hints.map(hint => hint.id)) + 1;
     }
     const hint = { id: newId, value: '' };
@@ -114,9 +116,9 @@ export const resetCardHooks = (updateSettings) => {
 
 export const scoringCardHooks = (scoring, updateSettings, defaultValue) => {
   let loadedAttemptsNumber = scoring.attempts.number;
-  if ((loadedAttemptsNumber === defaultValue || !_.isFinite(loadedAttemptsNumber)) && _.isFinite(defaultValue)) {
+  if ((loadedAttemptsNumber === defaultValue || !isFinite(loadedAttemptsNumber)) && isFinite(defaultValue)) {
     loadedAttemptsNumber = `${defaultValue} (Default)`;
-  } else if (loadedAttemptsNumber === defaultValue && _.isNil(defaultValue)) {
+  } else if (loadedAttemptsNumber === defaultValue && isNil(defaultValue)) {
     loadedAttemptsNumber = '';
   }
   const [attemptDisplayValue, setAttemptDisplayValue] = module.state.attemptDisplayValue(loadedAttemptsNumber);
@@ -135,9 +137,9 @@ export const scoringCardHooks = (scoring, updateSettings, defaultValue) => {
     let unlimitedAttempts = false;
     let attemptNumber = parseInt(event.target.value, 10);
 
-    if (!_.isFinite(attemptNumber) || attemptNumber === defaultValue) {
+    if (!isFinite(attemptNumber) || attemptNumber === defaultValue) {
       attemptNumber = null;
-      if (_.isFinite(defaultValue)) {
+      if (isFinite(defaultValue)) {
         setAttemptDisplayValue(`${defaultValue} (Default)`);
       } else {
         setAttemptDisplayValue('');
@@ -154,7 +156,7 @@ export const scoringCardHooks = (scoring, updateSettings, defaultValue) => {
     let newMaxAttempt = parseInt(event.target.value, 10);
     if (newMaxAttempt === defaultValue) {
       newMaxAttempt = `${defaultValue} (Default)`;
-    } else if (_.isNaN(newMaxAttempt)) {
+    } else if (isNaN(newMaxAttempt)) {
       newMaxAttempt = '';
     } else if (newMaxAttempt < 0) {
       newMaxAttempt = 0;
@@ -164,7 +166,7 @@ export const scoringCardHooks = (scoring, updateSettings, defaultValue) => {
 
   const handleWeightChange = (event) => {
     let weight = parseFloat(event.target.value);
-    if (_.isNaN(weight) || weight < 0) {
+    if (isNaN(weight) || weight < 0) {
       weight = 0;
     }
     updateSettings({ scoring: { ...scoring, weight } });
@@ -187,18 +189,18 @@ export const useAnswerSettings = (showAnswer, updateSettings) => {
   ];
 
   useEffect(() => {
-    setShowAttempts(_.includes(numberOfAttemptsChoice, showAnswer.on));
+    setShowAttempts(includes(numberOfAttemptsChoice, showAnswer.on));
   }, [showAttempts]);
 
   const handleShowAnswerChange = (event) => {
     const { value } = event.target;
-    setShowAttempts(_.includes(numberOfAttemptsChoice, value));
+    setShowAttempts(includes(numberOfAttemptsChoice, value));
     updateSettings({ showAnswer: { ...showAnswer, on: value } });
   };
 
   const handleAttemptsChange = (event) => {
     let attempts = parseInt(event.target.value, 10);
-    if (_.isNaN(attempts) || attempts < 0) {
+    if (isNaN(attempts) || attempts < 0) {
       attempts = 0;
     }
     updateSettings({ showAnswer: { ...showAnswer, afterAttempts: attempts } });
@@ -214,7 +216,7 @@ export const useAnswerSettings = (showAnswer, updateSettings) => {
 export const timerCardHooks = (updateSettings) => ({
   handleChange: (event) => {
     let time = parseInt(event.target.value, 10);
-    if (_.isNaN(time) || time < 0) {
+    if (isNaN(time) || time < 0) {
       time = 0;
     }
     updateSettings({ timeBetween: time });
diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/hooks.js
index c168392b37..c8aba6a229 100644
--- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/hooks.js
+++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/GeneralFeedback/hooks.js
@@ -1,5 +1,5 @@
 import { useState, useEffect } from 'react';
-import _ from 'lodash';
+import isEmpty from 'lodash/isEmpty';
 import messages from './messages';
 // This 'module' self-import hack enables mocking during tests.
 // See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
@@ -19,7 +19,7 @@ export const generalFeedbackHooks = (generalFeedback, updateSettings) => {
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   useEffect(() => {
-    if (_.isEmpty(generalFeedback)) {
+    if (isEmpty(generalFeedback)) {
       setSummary({ message: messages.noGeneralFeedbackSummary, values: {}, intl: true });
     } else {
       setSummary({
diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.jsx
index 3935c7cb7e..b0b56add19 100644
--- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.jsx
+++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.jsx
@@ -1,5 +1,5 @@
 import React from 'react';
-import _ from 'lodash';
+import isNil from 'lodash/isNil';
 import PropTypes from 'prop-types';
 import { connect } from 'react-redux';
 import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -76,7 +76,7 @@ const ScoringCard = ({
           className="mt-3 decoration-control-label"
           checked={scoring.attempts.unlimited}
           onChange={handleUnlimitedChange}
-          disabled={!_.isNil(defaultValue)}
+          disabled={!isNil(defaultValue)}
         >
           <div className="x-small">
             <FormattedMessage {...messages.unlimitedAttemptsCheckboxLabel} />
diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.js b/src/editors/containers/ProblemEditor/data/OLXParser.js
index a21d8c05d4..4a390e68cc 100644
--- a/src/editors/containers/ProblemEditor/data/OLXParser.js
+++ b/src/editors/containers/ProblemEditor/data/OLXParser.js
@@ -2,7 +2,9 @@
 /* eslint no-eval: 0 */
 
 import { XMLParser, XMLBuilder } from 'fast-xml-parser';
-import _ from 'lodash';
+import {
+  get, has, keys, isArray, isEmpty,
+} from 'lodash';
 import {
   ProblemTypeKeys,
   RichTextProblems,
@@ -92,7 +94,7 @@ export class OLXParser {
     const parser = new XMLParser(parserOptions);
     this.builder = new XMLBuilder(builderOptions);
     this.parsedOLX = parser.parse(olxString);
-    if (_.has(this.parsedOLX, 'problem')) {
+    if (has(this.parsedOLX, 'problem')) {
       this.problem = this.parsedOLX.problem;
     }
 
@@ -112,7 +114,7 @@ export class OLXParser {
     const richTextParser = new XMLParser(richTextOptions);
     this.richTextBuilder = new XMLBuilder(richTextBuilderOptions);
     this.richTextOLX = richTextParser.parse(olxString);
-    if (_.has(this.parsedOLX, 'problem')) {
+    if (has(this.parsedOLX, 'problem')) {
       this.richTextProblem = this.richTextOLX[0].problem;
     }
   }
@@ -188,17 +190,17 @@ export class OLXParser {
     );
     const answers = [];
     let data = {};
-    const widget = _.get(this.problem, `${problemType}.${widgetName}`);
+    const widget = get(this.problem, `${problemType}.${widgetName}`);
     const permissableTags = ['choice', '@_type', 'compoundhint', 'option', '#text'];
-    if (_.keys(widget).some((tag) => !permissableTags.includes(tag))) {
+    if (keys(widget).some((tag) => !permissableTags.includes(tag))) {
       throw new Error('Misc Tags, reverting to Advanced Editor');
     }
-    if (_.get(this.problem, `${problemType}.@_partial_credit`)) {
+    if (get(this.problem, `${problemType}.@_partial_credit`)) {
       throw new Error('Partial credit not supported by GUI, reverting to Advanced Editor');
     }
-    const choice = _.get(widget, option);
+    const choice = get(widget, option);
     const isComplexAnswer = RichTextProblems.includes(problemType);
-    if (_.isEmpty(choice)) {
+    if (isEmpty(choice)) {
       answers.push(
         {
           id: indexToLetterMap[answers.length],
@@ -206,7 +208,7 @@ export class OLXParser {
           correct: true,
         },
       );
-    } else if (_.isArray(choice)) {
+    } else if (isArray(choice)) {
       choice.forEach((element, index) => {
         const preservedAnswer = preservedAnswers[index].filter(answer => !Object.keys(answer).includes(`${option}hint`));
         const preservedFeedback = preservedAnswers[index].filter(answer => Object.keys(answer).includes(`${option}hint`));
@@ -265,11 +267,11 @@ export class OLXParser {
   getAnswerFeedback(preservedFeedback, hintKey) {
     const feedback = {};
     let feedbackKeys = 'selectedFeedback';
-    if (_.isEmpty(preservedFeedback)) { return feedback; }
+    if (isEmpty(preservedFeedback)) { return feedback; }
 
     preservedFeedback.forEach((feedbackArr) => {
-      if (_.has(feedbackArr, hintKey)) {
-        if (_.has(feedbackArr, ':@') && _.has(feedbackArr[':@'], '@_selected')) {
+      if (has(feedbackArr, hintKey)) {
+        if (has(feedbackArr, ':@') && has(feedbackArr[':@'], '@_selected')) {
           const isSelectedFeedback = feedbackArr[':@']['@_selected'] === 'true';
           feedbackKeys = isSelectedFeedback ? 'selectedFeedback' : 'unselectedFeedback';
         }
@@ -287,9 +289,9 @@ export class OLXParser {
    */
   getGroupedFeedback(choices) {
     const groupFeedback = [];
-    if (_.has(choices, 'compoundhint')) {
+    if (has(choices, 'compoundhint')) {
       const groupFeedbackArray = choices.compoundhint;
-      if (_.isArray(groupFeedbackArray)) {
+      if (isArray(groupFeedbackArray)) {
         groupFeedbackArray.forEach((element) => {
           const parsedFeedback = stripNonTextTags({ input: element, tag: '@_value' });
           groupFeedback.push({
@@ -338,12 +340,12 @@ export class OLXParser {
       selectedFeedback: firstFeedback,
     });
 
-    const additionalAnswerFeedback = preservedFeedback.filter(feedback => _.isArray(feedback));
-    const stringEqualHintFeedback = preservedFeedback.filter(feedback => !_.isArray(feedback));
+    const additionalAnswerFeedback = preservedFeedback.filter(feedback => isArray(feedback));
+    const stringEqualHintFeedback = preservedFeedback.filter(feedback => !isArray(feedback));
 
     // Parsing additional_answer for string response.
-    const additionalAnswer = _.get(stringresponse, 'additional_answer', []);
-    if (_.isArray(additionalAnswer)) {
+    const additionalAnswer = get(stringresponse, 'additional_answer', []);
+    if (isArray(additionalAnswer)) {
       additionalAnswer.forEach((newAnswer, indx) => {
         answerFeedback = this.getFeedback(additionalAnswerFeedback[indx]);
         answers.push({
@@ -364,8 +366,8 @@ export class OLXParser {
     }
 
     // Parsing stringequalhint for string response.
-    const stringEqualHint = _.get(stringresponse, 'stringequalhint', []);
-    if (_.isArray(stringEqualHint)) {
+    const stringEqualHint = get(stringresponse, 'stringequalhint', []);
+    if (isArray(stringEqualHint)) {
       stringEqualHint.forEach((newAnswer, indx) => {
         answerFeedback = this.getFeedback(stringEqualHintFeedback[indx]?.stringequalhint);
         answers.push({
@@ -387,9 +389,9 @@ export class OLXParser {
 
     // TODO: Support multiple types.
     additionalStringAttributes = {
-      type: _.get(stringresponse, '@_type'),
+      type: get(stringresponse, '@_type'),
       textline: {
-        size: _.get(stringresponse, 'textline.@_size'),
+        size: get(stringresponse, 'textline.@_size'),
       },
     };
 
@@ -416,16 +418,16 @@ export class OLXParser {
       'correcthint',
     );
     const { numericalresponse } = this.problem;
-    if (_.get(numericalresponse, '@_partial_credit')) {
+    if (get(numericalresponse, '@_partial_credit')) {
       throw new Error('Partial credit not supported by GUI, reverting to Advanced Editor');
     }
     let answerFeedback = '';
     const answers = [];
     let responseParam = {};
     const feedback = this.getFeedback(firstCorrectFeedback);
-    if (_.has(numericalresponse, 'responseparam')) {
-      const type = _.get(numericalresponse, 'responseparam.@_type');
-      const defaultValue = _.get(numericalresponse, 'responseparam.@_default');
+    if (has(numericalresponse, 'responseparam')) {
+      const type = get(numericalresponse, 'responseparam.@_type');
+      const defaultValue = get(numericalresponse, 'responseparam.@_default');
       responseParam = {
         [type]: defaultValue,
       };
@@ -441,8 +443,8 @@ export class OLXParser {
     });
 
     // Parsing additional_answer for numerical response.
-    const additionalAnswer = _.get(numericalresponse, 'additional_answer', []);
-    if (_.isArray(additionalAnswer)) {
+    const additionalAnswer = get(numericalresponse, 'additional_answer', []);
+    if (isArray(additionalAnswer)) {
       additionalAnswer.forEach((newAnswer, indx) => {
         answerFeedback = this.getFeedback(preservedFeedback[indx]);
         answers.push({
@@ -475,7 +477,7 @@ export class OLXParser {
    * @return {string} string of OLX
    */
   parseQuestions(problemType) {
-    const problemArray = _.get(this.richTextProblem[0], problemType) || this.richTextProblem;
+    const problemArray = get(this.richTextProblem[0], problemType) || this.richTextProblem;
 
     const questionArray = [];
     problemArray.forEach(tag => {
@@ -548,7 +550,7 @@ export class OLXParser {
    */
   getHints() {
     const hintsObject = [];
-    if (_.has(this.problem, 'demandhint.hint')) {
+    if (has(this.problem, 'demandhint.hint')) {
       const preservedProblem = this.richTextProblem;
       preservedProblem.forEach(obj => {
         const objKeys = Object.keys(obj);
@@ -578,21 +580,21 @@ export class OLXParser {
    * @return {string} string of OLX
    */
   getSolutionExplanation(problemType) {
-    if (!_.has(this.problem, `${problemType}.solution`) && !_.has(this.problem, 'solution')) { return null; }
+    if (!has(this.problem, `${problemType}.solution`) && !has(this.problem, 'solution')) { return null; }
     const [problemBody] = this.richTextProblem.filter(section => Object.keys(section).includes(problemType));
     const [solutionBody] = problemBody[problemType].filter(section => Object.keys(section).includes('solution'));
     const [divBody] = solutionBody.solution.filter(section => Object.keys(section).includes('div'));
     const solutionArray = [];
     if (divBody && divBody.div) {
       divBody.div.forEach(tag => {
-        const tagText = _.get(Object.values(tag)[0][0], '#text', '');
+        const tagText = get(Object.values(tag)[0][0], '#text', '');
         if (tagText.toString().trim() !== 'Explanation') {
           solutionArray.push(tag);
         }
       });
     } else {
       solutionBody.solution.forEach(tag => {
-        const tagText = _.get(Object.values(tag)[0][0], '#text', '');
+        const tagText = get(Object.values(tag)[0][0], '#text', '');
         if (tagText.toString().trim() !== 'Explanation') {
           solutionArray.push(tag);
         }
@@ -610,7 +612,7 @@ export class OLXParser {
    * @return {string} string of feedback
    */
   getFeedback(xmlElement) {
-    if (_.isEmpty(xmlElement)) { return ''; }
+    if (isEmpty(xmlElement)) { return ''; }
     const feedbackString = this.richTextBuilder.build(xmlElement);
     return feedbackString;
   }
@@ -636,7 +638,7 @@ export class OLXParser {
     }
     // make sure compound problems are treated as advanced
     if ((problemTypeKeys.length > 1)
-      || (_.isArray(this.problem[problemTypeKeys[0]])
+      || (isArray(this.problem[problemTypeKeys[0]])
         && this.problem[problemTypeKeys[0]].length > 1)) {
       return ProblemTypeKeys.ADVANCED;
     }
@@ -671,7 +673,7 @@ export class OLXParser {
   }
 
   getParsedOLXData() {
-    if (_.isEmpty(this.problem)) {
+    if (isEmpty(this.problem)) {
       return {};
     }
 
@@ -720,16 +722,16 @@ export class OLXParser {
         return {};
     }
     const generalFeedback = this.getGeneralFeedback({ answers: answersObject.answers, problemType });
-    if (_.has(answersObject, 'additionalStringAttributes')) {
+    if (has(answersObject, 'additionalStringAttributes')) {
       additionalAttributes = { ...answersObject.additionalStringAttributes };
     }
 
-    if (_.has(answersObject, 'groupFeedbackList')) {
+    if (has(answersObject, 'groupFeedbackList')) {
       groupFeedbackList = answersObject.groupFeedbackList;
     }
     const { answers } = answersObject;
     const settings = { hints };
-    if (ProblemTypeKeys.NUMERIC === problemType && _.has(answers[0], 'tolerance')) {
+    if (ProblemTypeKeys.NUMERIC === problemType && has(answers[0], 'tolerance')) {
       const toleranceValue = answers[0].tolerance;
       if (!toleranceValue || toleranceValue.length === 0) {
         settings.tolerance = { value: null, type: 'None' };
diff --git a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js
index 673763de61..5f2d8ca9f7 100644
--- a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js
+++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js
@@ -1,4 +1,4 @@
-import _ from 'lodash';
+import { get, has } from 'lodash';
 import { XMLParser, XMLBuilder } from 'fast-xml-parser';
 import { ProblemTypeKeys } from '../../../data/constants/problem';
 import { ToleranceTypes } from '../components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants';
@@ -169,7 +169,7 @@ class ReactStateOLXParser {
         choice.push(singleAnswer);
       }
     });
-    if (_.has(this.problemState, 'groupFeedbackList') && problemType === ProblemTypeKeys.MULTISELECT) {
+    if (has(this.problemState, 'groupFeedbackList') && problemType === ProblemTypeKeys.MULTISELECT) {
       compoundhint = this.addGroupFeedbackList();
       choice.push(...compoundhint);
     }
@@ -333,7 +333,7 @@ class ReactStateOLXParser {
           answerObject = {
             ':@': {
               '@_answer': answer.title,
-              '@_type': _.get(this.problemState, 'additionalAttributes.type', 'ci'),
+              '@_type': get(this.problemState, 'additionalAttributes.type', 'ci'),
             },
             [problemType]: [...correcthint],
           };
@@ -348,7 +348,7 @@ class ReactStateOLXParser {
     });
     answerObject[problemType].push({
       textline: { '#text': '' },
-      ':@': { '@_size': _.get(this.problemState, 'additionalAttributes.textline.size', 20) },
+      ':@': { '@_size': get(this.problemState, 'additionalAttributes.textline.size', 20) },
     });
     return answerObject;
   }
@@ -490,7 +490,7 @@ class ReactStateOLXParser {
    * @return {bool}
    */
   hasAttributeWithValue(obj, attr) {
-    return _.has(obj, attr) && _.get(obj, attr, '').toString().trim() !== '';
+    return has(obj, attr) && get(obj, attr, '').toString().trim() !== '';
   }
 
   buildOLX() {
diff --git a/src/editors/containers/ProblemEditor/data/ReactStateSettingsParser.js b/src/editors/containers/ProblemEditor/data/ReactStateSettingsParser.js
index 0bc2177849..7cad5b16ad 100644
--- a/src/editors/containers/ProblemEditor/data/ReactStateSettingsParser.js
+++ b/src/editors/containers/ProblemEditor/data/ReactStateSettingsParser.js
@@ -1,5 +1,5 @@
 import { XMLParser } from 'fast-xml-parser';
-import _ from 'lodash';
+import { includes } from 'lodash';
 
 import {
   ShowAnswerTypesKeys,
@@ -34,7 +34,7 @@ class ReactStateSettingsParser {
     settings = popuplateItem(settings, 'number', 'max_attempts', stateSettings.scoring.attempts, defaultSettings?.maxAttempts, true);
     settings = popuplateItem(settings, 'weight', 'weight', stateSettings.scoring);
     settings = popuplateItem(settings, 'on', 'showanswer', stateSettings.showAnswer, defaultSettings?.showanswer, true);
-    if (_.includes(numberOfAttemptsChoice, stateSettings.showAnswer.on)) {
+    if (includes(numberOfAttemptsChoice, stateSettings.showAnswer.on)) {
       settings = popuplateItem(settings, 'afterAttempts', 'attempts_before_showanswer_button', stateSettings.showAnswer);
     }
     settings = popuplateItem(settings, 'showResetButton', 'show_reset_button', stateSettings, defaultSettings?.showResetButton, true);
diff --git a/src/editors/containers/ProblemEditor/data/SettingsParser.js b/src/editors/containers/ProblemEditor/data/SettingsParser.js
index afc56bde41..55a84349f6 100644
--- a/src/editors/containers/ProblemEditor/data/SettingsParser.js
+++ b/src/editors/containers/ProblemEditor/data/SettingsParser.js
@@ -1,15 +1,17 @@
-import _ from 'lodash';
+import {
+  get, isEmpty, isFinite, isNil,
+} from 'lodash';
 
 import { ShowAnswerTypes, RandomizationTypesKeys } from '../../../data/constants/problem';
 
 export const popuplateItem = (parentObject, itemName, statekey, metadata, defaultValue = null, allowNull = false) => {
   let parent = parentObject;
-  const item = _.get(metadata, itemName, null);
+  const item = get(metadata, itemName, null);
 
   // if item is null, undefined, or empty string, use defaultValue
-  const finalValue = (_.isNil(item) || item === '') ? defaultValue : item;
+  const finalValue = (isNil(item) || item === '') ? defaultValue : item;
 
-  if (!_.isNil(finalValue) || allowNull) {
+  if (!isNil(finalValue) || allowNull) {
     parent = { ...parentObject, [statekey]: finalValue };
   }
   return parent;
@@ -19,18 +21,18 @@ export const parseScoringSettings = (metadata, defaultSettings) => {
   let scoring = {};
 
   const attempts = popuplateItem({}, 'max_attempts', 'number', metadata);
-  const initialAttempts = _.get(attempts, 'number', null);
-  const defaultAttempts = _.get(defaultSettings, 'max_attempts', null);
+  const initialAttempts = get(attempts, 'number', null);
+  const defaultAttempts = get(defaultSettings, 'max_attempts', null);
   attempts.unlimited = false;
 
   // isFinite checks if value is a finite primitive number.
-  if (!_.isFinite(initialAttempts) || initialAttempts === defaultAttempts) {
+  if (!isFinite(initialAttempts) || initialAttempts === defaultAttempts) {
     // set number to null in any case as lms will pick default value if it exists.
     attempts.number = null;
   }
 
   // if both block number and default number are null set unlimited to true.
-  if (_.isNil(initialAttempts) && _.isNil(defaultAttempts)) {
+  if (isNil(initialAttempts) && isNil(defaultAttempts)) {
     attempts.unlimited = true;
   }
 
@@ -48,8 +50,8 @@ export const parseScoringSettings = (metadata, defaultSettings) => {
 export const parseShowAnswer = (metadata) => {
   let showAnswer = {};
 
-  const showAnswerType = _.get(metadata, 'showanswer', {});
-  if (!_.isNil(showAnswerType) && showAnswerType in ShowAnswerTypes) {
+  const showAnswerType = get(metadata, 'showanswer', {});
+  if (!isNil(showAnswerType) && showAnswerType in ShowAnswerTypes) {
     showAnswer = { ...showAnswer, on: showAnswerType };
   }
 
@@ -61,22 +63,22 @@ export const parseShowAnswer = (metadata) => {
 export const parseSettings = (metadata, defaultSettings) => {
   let settings = {};
 
-  if (_.isNil(metadata) || _.isEmpty(metadata)) {
+  if (isNil(metadata) || isEmpty(metadata)) {
     return settings;
   }
 
   const scoring = parseScoringSettings(metadata, defaultSettings);
-  if (!_.isEmpty(scoring)) {
+  if (!isEmpty(scoring)) {
     settings = { ...settings, scoring };
   }
 
   const showAnswer = parseShowAnswer(metadata);
-  if (!_.isEmpty(showAnswer)) {
+  if (!isEmpty(showAnswer)) {
     settings = { ...settings, showAnswer };
   }
 
-  const randomizationType = _.get(metadata, 'rerandomize', {});
-  if (!_.isEmpty(randomizationType) && Object.values(RandomizationTypesKeys).includes(randomizationType)) {
+  const randomizationType = get(metadata, 'rerandomize', {});
+  if (!isEmpty(randomizationType) && Object.values(RandomizationTypesKeys).includes(randomizationType)) {
     settings = popuplateItem(settings, 'rerandomize', 'randomization', metadata);
   }
 
diff --git a/src/editors/data/redux/problem/reducers.ts b/src/editors/data/redux/problem/reducers.ts
index f802be028f..ed3f91a26b 100644
--- a/src/editors/data/redux/problem/reducers.ts
+++ b/src/editors/data/redux/problem/reducers.ts
@@ -1,4 +1,4 @@
-import _ from 'lodash';
+import { has } from 'lodash';
 import { createSlice } from '@reduxjs/toolkit';
 import { indexToLetterMap } from '../../../containers/ProblemEditor/data/OLXParser';
 import { StrictDict } from '../../../utils';
@@ -61,17 +61,17 @@ const problem = createSlice({
       let { correctAnswerCount } = state;
       const answers = state.answers.map(obj => {
         if (obj.id === id) {
-          if (_.has(answer, 'correct') && payload.correct) {
+          if (has(answer, 'correct') && payload.correct) {
             correctAnswerCount += 1;
           }
-          if (_.has(answer, 'correct') && payload.correct === false && correctAnswerCount > 0) {
+          if (has(answer, 'correct') && payload.correct === false && correctAnswerCount > 0) {
             correctAnswerCount -= 1;
           }
           return { ...obj, ...answer };
         }
         // set other answers as incorrect if problem only has one answer correct
         // and changes object include correct key change
-        if (hasSingleAnswer && _.has(answer, 'correct') && obj.correct) {
+        if (hasSingleAnswer && has(answer, 'correct') && obj.correct) {
           return { ...obj, correct: false };
         }
         return obj;
diff --git a/src/editors/data/redux/thunkActions/problem.ts b/src/editors/data/redux/thunkActions/problem.ts
index 5d4c11df60..c10360e8c1 100644
--- a/src/editors/data/redux/thunkActions/problem.ts
+++ b/src/editors/data/redux/thunkActions/problem.ts
@@ -1,4 +1,4 @@
-import _ from 'lodash';
+import { get, isEmpty } from 'lodash';
 import { actions as problemActions } from '../problem';
 import { actions as requestActions } from '../requests';
 import { selectors as appSelectors } from '../app';
@@ -47,7 +47,7 @@ export const getDataFromOlx = ({ rawOLX, rawSettings, defaultSettings }) => {
   }
   const { settings, ...data } = parsedProblem;
   const parsedSettings = { ...settings, ...parseSettings(rawSettings, defaultSettings) };
-  if (!_.isEmpty(rawOLX) && !_.isEmpty(data)) {
+  if (!isEmpty(rawOLX) && !isEmpty(data)) {
     return { ...data, rawOLX, settings: parsedSettings };
   }
   return { settings: parsedSettings };
@@ -79,8 +79,8 @@ export const fetchAdvancedSettings = ({ rawOLX, rawSettings }) => (dispatch) =>
 };
 
 export const initializeProblem = (blockValue) => (dispatch, getState) => {
-  const rawOLX = _.get(blockValue, 'data.data', '');
-  const rawSettings = _.get(blockValue, 'data.metadata', {});
+  const rawOLX = get(blockValue, 'data.data', '');
+  const rawSettings = get(blockValue, 'data.metadata', {});
   const learningContextId = selectors.app.learningContextId(getState());
   if (isLibraryKey(learningContextId)) {
     // Content libraries don't yet support defaults for fields like max_attempts, showanswer, etc.
diff --git a/src/editors/data/redux/thunkActions/video.js b/src/editors/data/redux/thunkActions/video.js
index c84a3913c4..1bb2a8db62 100644
--- a/src/editors/data/redux/thunkActions/video.js
+++ b/src/editors/data/redux/thunkActions/video.js
@@ -1,4 +1,4 @@
-import _, { isEmpty } from 'lodash';
+import { has, find, isEmpty } from 'lodash';
 import { removeItemOnce } from '../../../utils';
 import * as requests from './requests';
 // This 'module' self-import hack enables mocking during tests.
@@ -21,8 +21,8 @@ export const loadVideoData = (selectedVideoId, selectedVideoUrl) => (dispatch, g
   let rawVideoData = blockValueData?.metadata ? blockValueData.metadata : {};
   const rawVideos = Object.values(selectors.app.videos(state));
   if (selectedVideoId !== undefined && selectedVideoId !== null) {
-    const selectedVideo = _.find(rawVideos, video => {
-      if (_.has(video, 'edx_video_id')) {
+    const selectedVideo = find(rawVideos, video => {
+      if (has(video, 'edx_video_id')) {
         return video.edx_video_id === selectedVideoId;
       }
       return false;
diff --git a/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.jsx b/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.jsx
index f0739e1e4b..683ae21072 100644
--- a/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.jsx
+++ b/src/editors/sharedComponents/TypeaheadDropdown/FormGroup.jsx
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import _ from 'lodash';
+import { isEmpty } from 'lodash';
 import { Form } from '@openedx/paragon';
 
 const FormGroup = (props) => {
@@ -35,13 +35,13 @@ const FormGroup = (props) => {
 
       {props.children}
 
-      {props.helpText && _.isEmpty(props.errorMessage) && (
+      {props.helpText && isEmpty(props.errorMessage) && (
         <Form.Control.Feedback type="default" key="help-text">
           {props.helpText}
         </Form.Control.Feedback>
       )}
 
-      {!_.isEmpty(props.errorMessage) && (
+      {!isEmpty(props.errorMessage) && (
         <Form.Control.Feedback
           type="invalid"
           key="error"
diff --git a/src/files-and-videos/generic/table-components/TableActions.jsx b/src/files-and-videos/generic/table-components/TableActions.jsx
index f73e34822c..3e813e6c4c 100644
--- a/src/files-and-videos/generic/table-components/TableActions.jsx
+++ b/src/files-and-videos/generic/table-components/TableActions.jsx
@@ -1,5 +1,5 @@
 import React, { useContext, useEffect } from 'react';
-import _ from 'lodash';
+import { isEmpty } from 'lodash';
 import { PropTypes } from 'prop-types';
 import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
 import { getConfig } from '@edx/frontend-platform';
@@ -57,7 +57,7 @@ const TableActions = ({
           ) : null}
           <Dropdown.Item
             onClick={() => handleBulkDownload(selectedFlatRows)}
-            disabled={_.isEmpty(selectedFlatRows)}
+            disabled={isEmpty(selectedFlatRows)}
           >
             <FormattedMessage {...messages.downloadTitle} />
           </Dropdown.Item>
@@ -65,7 +65,7 @@ const TableActions = ({
           <Dropdown.Item
             data-testid="open-delete-confirmation-button"
             onClick={() => handleOpenDeleteConfirmation(selectedFlatRows)}
-            disabled={_.isEmpty(selectedFlatRows)}
+            disabled={isEmpty(selectedFlatRows)}
           >
             <FormattedMessage {...messages.deleteTitle} />
           </Dropdown.Item>
diff --git a/src/generic/course-upload-image/index.jsx b/src/generic/course-upload-image/index.jsx
index 42a4bb5725..d0cff97fb6 100644
--- a/src/generic/course-upload-image/index.jsx
+++ b/src/generic/course-upload-image/index.jsx
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import _ from 'lodash';
+import last from 'lodash/last';
 import { useParams } from 'react-router-dom';
 import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
 import { FileUpload as FileUploadIcon } from '@openedx/paragon/icons';
@@ -35,9 +35,9 @@ const CourseUploadImage = ({
   const assetsUrl = () => new URL(`/assets/${courseId}`, getConfig().STUDIO_BASE_URL);
 
   const handleChangeImageAsset = (path) => {
-    const assetPath = _.last(path.split('/'));
+    const assetPath = last(path.split('/'));
     // If image path is entered directly, we need to strip the asset prefix
-    const imageName = _.last(assetPath.split('block@'));
+    const imageName = last(assetPath.split('block@'));
     onChange(path, assetImageField);
     if (imageNameField) {
       onChange(imageName, imageNameField);
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/DivisionByGroupFields.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/DivisionByGroupFields.jsx
index ebc6663f90..ea1b180289 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/shared/DivisionByGroupFields.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/DivisionByGroupFields.jsx
@@ -5,7 +5,7 @@ import {
 } from '@openedx/paragon';
 import { AppContext } from '@edx/frontend-platform/react';
 import { FieldArray, useFormikContext } from 'formik';
-import _ from 'lodash';
+import size from 'lodash/size';
 import { useParams } from 'react-router-dom';
 import FormSwitchGroup from '../../../../../generic/FormSwitchGroup';
 import messages from '../../messages';
@@ -34,7 +34,7 @@ const DivisionByGroupFields = ({ intl }) => {
 
   useEffect(() => {
     if (divideByCohorts) {
-      if (!divideCourseTopicsByCohorts && _.size(discussionTopics) !== _.size(divideDiscussionIds)) {
+      if (!divideCourseTopicsByCohorts && size(discussionTopics) !== size(divideDiscussionIds)) {
         setFieldValue('divideDiscussionIds', discussionTopics.map(topic => topic.id));
       }
     } else {
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-restrictions/DiscussionRestrictionItem.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-restrictions/DiscussionRestrictionItem.jsx
index 8bf79dc97a..f9010a345b 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-restrictions/DiscussionRestrictionItem.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-restrictions/DiscussionRestrictionItem.jsx
@@ -3,7 +3,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
 import { Form } from '@openedx/paragon';
 import { useFormikContext } from 'formik';
 import PropTypes from 'prop-types';
-import _ from 'lodash';
+import { startCase, toLower } from 'lodash';
 
 import messages from '../../../messages';
 import RestrictDatesInput from './RestrictDatesInput';
@@ -53,7 +53,7 @@ const DiscussionRestrictionItem = ({
       collapseHeadingText={formatRestrictedDates(restrictedDate)}
       badgeVariant={badgeVariant[restrictedDate.status]}
       badgeStatus={intl.formatMessage(messages.restrictedDatesStatus, {
-        status: _.startCase(_.toLower(restrictedDate.status)),
+        status: startCase(toLower(restrictedDate.status)),
       })}
     />
   ), [restrictedDate]);
diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx
index b0a4959f90..d279533f58 100644
--- a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx
+++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx
@@ -4,7 +4,7 @@ import { Button } from '@openedx/paragon';
 import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
 import { FieldArray, useFormikContext } from 'formik';
 import { v4 as uuid } from 'uuid';
-import _ from 'lodash';
+import { remove as removeElements, uniq, uniqBy } from 'lodash';
 import messages from '../../../messages';
 import TopicItem from './TopicItem';
 import { OpenedXConfigFormContext } from '../../openedx/OpenedXConfigFormProvider';
@@ -36,10 +36,10 @@ const DiscussionTopics = ({ intl }) => {
     } else {
       setValidDiscussionTopics(currentValidTopics => {
         const allDiscussionTopics = [...currentValidTopics, ...discussionTopics.filter(topic => topic.id === id)];
-        const allValidTopics = _.remove(allDiscussionTopics, topic => topic.name !== '');
-        return _.uniqBy(allValidTopics, 'id');
+        const allValidTopics = removeElements(allDiscussionTopics, topic => topic.name !== '');
+        return uniqBy(allValidTopics, 'id');
       });
-      setFieldValue('divideDiscussionIds', _.uniq([...divideDiscussionIds, id]));
+      setFieldValue('divideDiscussionIds', uniq([...divideDiscussionIds, id]));
     }
   }, [divideDiscussionIds, discussionTopics]);
 
diff --git a/src/pages-and-resources/discussions/app-config-form/utils.js b/src/pages-and-resources/discussions/app-config-form/utils.js
index 0584c940b6..90a71d58d2 100644
--- a/src/pages-and-resources/discussions/app-config-form/utils.js
+++ b/src/pages-and-resources/discussions/app-config-form/utils.js
@@ -1,5 +1,5 @@
 import moment from 'moment';
-import _ from 'lodash';
+import orderBy from 'lodash/orderBy';
 import { getIn } from 'formik';
 import { restrictedDatesStatus as constants } from '../data/constants';
 
@@ -48,7 +48,7 @@ export const decodeDateTime = (date, time) => {
 };
 
 export const sortRestrictedDatesByStatus = (data, status, order) => (
-  _.orderBy(
+  orderBy(
     data.filter(date => date.status === status),
     [(obj) => decodeDateTime(obj.startDate, startOfDayTime(obj.startTime))],
     [order],
diff --git a/src/pages-and-resources/discussions/app-list/FeaturesTable.jsx b/src/pages-and-resources/discussions/app-list/FeaturesTable.jsx
index 011f2bbc84..b3772ad31a 100644
--- a/src/pages-and-resources/discussions/app-list/FeaturesTable.jsx
+++ b/src/pages-and-resources/discussions/app-list/FeaturesTable.jsx
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { Remove, Check } from '@openedx/paragon/icons';
 import { DataTable } from '@openedx/paragon';
 import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-import _ from 'lodash';
+import groupBy from 'lodash/groupBy';
 
 import messages from './messages';
 import appMessages from '../app-config-form/messages';
@@ -13,7 +13,7 @@ import './FeaturesTable.scss';
 const FeaturesTable = ({ apps, features, intl }) => {
   const {
     basic, partial, full, common,
-  } = _.groupBy(features, (feature) => feature.featureSupportType);
+  } = groupBy(features, (feature) => feature.featureSupportType);
 
   const createRow = (feature) => {
     const appCheckmarkCells = {};


Reviews:
user: login: bradenmacdonald
body: Thanks! LGTM but let's get a green build before we merge.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T21:03:53Z
user: login: bradenmacdonald
body: Not sure if these are _all_ the issues, but these are some of the things causing test failures. If you rebase you'll also get some improvements that make the tests more robust, from the React 18 PR that recently merged.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T02:21:22Z
user: login: regisb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T08:16:15Z
user: login: regisb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T08:22:28Z
user: login: bradenmacdonald
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T00:07:07Z

Review comments:
diff_hunk: @@ -4,7 +4,7 @@ import { Button } from '@openedx/paragon';
 import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
 import { FieldArray, useFormikContext } from 'formik';
 import { v4 as uuid } from 'uuid';
-import _ from 'lodash';
+import { remove as removeElements, uniq, uniqBy } from 'lodash/remove';
path: src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx
user: login: bradenmacdonald
body: ```suggestion
import { remove as removeElements, uniq, uniqBy } from 'lodash';
```
This was causing failures at runtime / test time (you can't import `uniq` from the `remove` file). Not caught sooner because this file isn't using TypeScript.
created_at: 2025-04-08T02:04:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,5 @@
 import React from 'react';
-import _ from 'lodash';
+import isNil from 'lodash';
path: src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.jsx
user: login: bradenmacdonald
body: This is wrong - it's importing the lodash full build (all functions, normally called `_`) under the name `isNil`. It's still a function so it doesn't obviously break, but it's definitely wrong. I'm not sure if `import { isNil } from 'lodash';` is going to work either because that version is designed to be used as NodeJS modules, not `import` ed. I think the following is the best / most correct:

```suggestion
import { isNil } from 'lodash-es';
```
See https://stackoverflow.com/a/50656694

Though this also requires changing the installed version from `lodash` to `lodash-es`. This also requires a hack to work with Jest (via `moduleNameMapper` in `jest.config.js`) but that hack is already in place for this MFE.
created_at: 2025-04-08T02:17:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import _ from 'lodash';
+import last from 'lodash';
path: src/generic/course-upload-image/index.jsx
user: login: bradenmacdonald
body: ```suggestion
import { last } from 'lodash';
```
or `from 'lodash-es';`
created_at: 2025-04-08T02:20:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5,7 +5,7 @@ import {
 } from '@openedx/paragon';
 import { AppContext } from '@edx/frontend-platform/react';
 import { FieldArray, useFormikContext } from 'formik';
-import _ from 'lodash';
+import size from 'lodash';
path: src/pages-and-resources/discussions/app-config-form/apps/shared/DivisionByGroupFields.jsx
user: login: bradenmacdonald
body: ```suggestion
import { size } from 'lodash';
```
created_at: 2025-04-08T02:20:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,5 @@
 import moment from 'moment';
-import _ from 'lodash';
+import orderBy from 'lodash';
path: src/pages-and-resources/discussions/app-config-form/utils.js
user: login: bradenmacdonald
body: ```suggestion
import { orderBy } from 'lodash';
```
created_at: 2025-04-08T02:20:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { Remove, Check } from '@openedx/paragon/icons';
 import { DataTable } from '@openedx/paragon';
 import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-import _ from 'lodash';
+import groupBy from 'lodash';
path: src/pages-and-resources/discussions/app-list/FeaturesTable.jsx
user: login: bradenmacdonald
body: ```suggestion
import { groupBy } from 'lodash';
```
created_at: 2025-04-08T02:20:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,5 @@
 import React from 'react';
-import _ from 'lodash';
+import isNil from 'lodash';
path: src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ScoringCard.jsx
user: login: regisb
body: For single imports like this one, we can also write: `import last from 'lodash/last'`. I'm updating this PR to use this more explicit syntax. Let me know if you agree.
created_at: 2025-04-08T08:16:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,7 +4,7 @@ import { Button } from '@openedx/paragon';
 import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
 import { FieldArray, useFormikContext } from 'formik';
 import { v4 as uuid } from 'uuid';
-import _ from 'lodash';
+import { remove as removeElements, uniq, uniqBy } from 'lodash/remove';
path: src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.jsx
user: login: regisb
body: Thanks for the help! And sorry about the many mistakes...
created_at: 2025-04-08T08:22:28Z
author_association: CONTRIBUTOR

PR comments:
user: login: openedx-webhooks
created_at: 2025-04-01T12:14:57Z
body: Thanks for the pull request, @regisb!


This repository is currently maintained by `@openedx/2u-tnl`.


Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

<details><summary>:radio_button: Get product approval</summary>

If you haven't already, [check this list](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review#Does-my-contribution-require-Product-Review%3F) to see if your contribution needs to go through the product review process.

- If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the [Product Working Group](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3449028609/Product+Working+Group).
    - This process (including the steps you'll need to take) is documented [here](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review#Product-Review-Process).
- If it doesn't, simply proceed with the next step.
</details>

<details><summary>:radio_button: Provide context</summary>

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

- Dependencies
  > This PR must be merged before / after / at the same time as ...
- Blockers
  > This PR is waiting for OEP-1234 to be accepted.
- Timeline information
  > This PR must be merged by XX date **because** ...
- Partner information
  > This is for a course on edx.org.
- Supporting documentation
- Relevant [Open edX discussion forum](https://discuss.openedx.org/) threads
</details>





<details><summary>:radio_button: Get a green build</summary>

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.
</details><details>


---

<details><summary>Where can I find more information?</summary>

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

- [Overview of Review Process for Community Contributions](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/FAQ-about-pull-requests.html)
- [Pull Request Status Guide](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/pull-request-statuses.html)
- [Making changes to your pull request](https://docs.openedx.org/en/latest/documentors/how-tos/make_changes_to_your_pull_request.html)
</details>

<details><summary>When can I expect my changes to be merged?</summary>

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

- The size and impact of the changes that it introduces
- The need for product review
- Maintenance status of the parent repository

:bulb: *As a result it may take up to several weeks or months to complete a review and merge your PR.*
</details>
<!-- comment:external_pr -->
<!-- data: eyJkcmFmdCI6IGZhbHNlfQ== -->

author_association: NONE
user: login: regisb
created_at: 2025-04-01T13:05:01Z
body: Issues are not related to this PR. This is ready for review.
author_association: CONTRIBUTOR
user: login: regisb
created_at: 2025-04-02T21:06:37Z
body: Can you help me to figure out what's going on with the build? As far as I understand, other PRs in this repo face similar errors.
author_association: CONTRIBUTOR
user: login: bradenmacdonald
created_at: 2025-04-08T15:10:07Z
body: No problem. I actually tried to do a PR like this once myself, but ran into some challenge and move on to other things. So I'm glad to see you tackling it. Looks like you're down to only 1 failing test now :)
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-14T07:31:46Z
body: ## [Codecov](https://app.codecov.io/gh/openedx/frontend-app-authoring/pull/1772?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) Report
Attention: Patch coverage is `96.80851%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 93.54%. Comparing base [(`a43027b`)](https://app.codecov.io/gh/openedx/frontend-app-authoring/commit/a43027b328ec41c916e5ea887667b146d1c5182f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) to head [(`1d9fff6`)](https://app.codecov.io/gh/openedx/frontend-app-authoring/commit/1d9fff641036b27ae3ed2b7b340a14e62d21c54b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx).
> Report is 11 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/openedx/frontend-app-authoring/pull/1772?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) | Patch % | Lines |
|---|---|---|
| [...components/EditProblemView/SettingsWidget/hooks.js](https://app.codecov.io/gh/openedx/frontend-app-authoring/pull/1772?src=pr&el=tree&filepath=src%2Feditors%2Fcontainers%2FProblemEditor%2Fcomponents%2FEditProblemView%2FSettingsWidget%2Fhooks.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx#diff-c3JjL2VkaXRvcnMvY29udGFpbmVycy9Qcm9ibGVtRWRpdG9yL2NvbXBvbmVudHMvRWRpdFByb2JsZW1WaWV3L1NldHRpbmdzV2lkZ2V0L2hvb2tzLmpz) | 90.90% | [1 Missing :warning: ](https://app.codecov.io/gh/openedx/frontend-app-authoring/pull/1772?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) |
| [...Widget/settingsComponents/GeneralFeedback/hooks.js](https://app.codecov.io/gh/openedx/frontend-app-authoring/pull/1772?src=pr&el=tree&filepath=src%2Feditors%2Fcontainers%2FProblemEditor%2Fcomponents%2FEditProblemView%2FSettingsWidget%2FsettingsComponents%2FGeneralFeedback%2Fhooks.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx#diff-c3JjL2VkaXRvcnMvY29udGFpbmVycy9Qcm9ibGVtRWRpdG9yL2NvbXBvbmVudHMvRWRpdFByb2JsZW1WaWV3L1NldHRpbmdzV2lkZ2V0L3NldHRpbmdzQ29tcG9uZW50cy9HZW5lcmFsRmVlZGJhY2svaG9va3MuanM=) | 0.00% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/openedx/frontend-app-authoring/pull/1772?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) |
| [src/editors/data/redux/problem/reducers.ts](https://app.codecov.io/gh/openedx/frontend-app-authoring/pull/1772?src=pr&el=tree&filepath=src%2Feditors%2Fdata%2Fredux%2Fproblem%2Freducers.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx#diff-c3JjL2VkaXRvcnMvZGF0YS9yZWR1eC9wcm9ibGVtL3JlZHVjZXJzLnRz) | 75.00% | [1 Missing :warning: ](https://app.codecov.io/gh/openedx/frontend-app-authoring/pull/1772?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) |

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #1772   +/-   ##
=======================================
  Coverage   93.54%   93.54%           
=======================================
  Files        1136     1136           
  Lines       23205    23205           
  Branches     5000     4999    -1     
=======================================
  Hits        21708    21708           
- Misses       1421     1429    +8     
+ Partials       76       68    -8     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/openedx/frontend-app-authoring/pull/1772?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: regisb
created_at: 2025-04-14T07:34:48Z
body: OK I think there might have been a flaky test... This should be good to merge now.
author_association: CONTRIBUTOR