From 91a21e6c1d258cdde10b4c78717af4e8f90f2ed2 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Thu, 11 Sep 2025 16:18:55 -0600 Subject: [PATCH 1/2] refactor: Replace of injectIntl with useIntl --- src/components/CodeAssignmentModal/index.jsx | 483 +++++++-------- .../CodeManagement/ManageCodesTab.jsx | 554 ++++++++---------- src/components/CodeReminderModal/index.jsx | 243 ++++---- src/components/Footer/index.jsx | 136 ++--- src/components/LearnerActivityTable/index.jsx | 69 +-- 5 files changed, 675 insertions(+), 810 deletions(-) diff --git a/src/components/CodeAssignmentModal/index.jsx b/src/components/CodeAssignmentModal/index.jsx index d2326c3073..3fdc4c2c25 100644 --- a/src/components/CodeAssignmentModal/index.jsx +++ b/src/components/CodeAssignmentModal/index.jsx @@ -1,10 +1,12 @@ -import React from 'react'; +import React, { + useState, useRef, useEffect, useCallback, +} from 'react'; import PropTypes from 'prop-types'; import { reduxForm, SubmissionError } from 'redux-form'; import { Button, ModalDialog, ActionRow, Form, Spinner, } from '@openedx/paragon'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import isEmail from 'validator/lib/isEmail'; @@ -35,166 +37,70 @@ import { } from './constants'; import { getErrors } from './validation'; -export class BaseCodeAssignmentModal extends React.Component { - constructor(props) { - super(props); - - this.errorMessageRef = React.createRef(); - - this.state = { - mode: MODAL_TYPES.assign, - notify: true, - }; - - this.setMode = this.setMode.bind(this); - this.setNotify = this.setNotify.bind(this); - this.validateFormData = this.validateFormData.bind(this); - this.handleModalSubmit = this.handleModalSubmit.bind(this); - this.getNumberOfSelectedCodes = this.getNumberOfSelectedCodes.bind(this); - } - - componentDidUpdate(prevProps) { - const { - submitFailed, - submitSucceeded, - onClose, - error, - } = this.props; - const { - mode, - } = this.state; - - const errorMessageRef = this.errorMessageRef && this.errorMessageRef.current; - - if (mode === MODAL_TYPES.assign && submitSucceeded && submitSucceeded !== prevProps.submitSucceeded) { +export const BaseCodeAssignmentModal = (props) => { + const { + submitFailed, + submitSucceeded, + onClose, + error, + setEmailAddress, + isBulkAssign, + couponId, + data = {}, + sendCodeAssignment, + createPendingEnterpriseUsers, + enableLearnerPortal, + enterpriseSlug, + enterpriseUuid, + couponDetailsTable, + currentEmail, + handleSubmit, + submitting, + title, + } = props; + + const { formatMessage } = useIntl(); + + const { + code, + selectedCodes, + hasAllCodesSelected, + } = data; + + const errorMessageRef = useRef(); + const [mode, setMode] = useState(MODAL_TYPES.assign); + const [notify, setNotify] = useState(true); + + useEffect(() => { + const errorMessageElement = errorMessageRef.current; + + if (mode === MODAL_TYPES.assign && submitSucceeded) { onClose(); } - if (submitFailed && error !== prevProps.error && errorMessageRef) { - // When there is an new error, focus on the error message status alert - errorMessageRef.focus(); + if (submitFailed && error && errorMessageElement) { + errorMessageElement.focus(); } - } - - componentWillUnmount() { - this.props.setEmailAddress('', MODAL_TYPES.assign); - } - - handleModalSubmit(formData) { - const { - isBulkAssign, - couponId, - data: { - code, - selectedCodes, - hasAllCodesSelected, - }, - sendCodeAssignment, - createPendingEnterpriseUsers, - enableLearnerPortal, - enterpriseSlug, - enterpriseUuid, - } = this.props; - - this.setMode(MODAL_TYPES.assign); - - const { notify } = this.state; - - // Validate form data - this.validateFormData(formData); - // Configure the options to send to the assignment API endpoint - const options = { - template: formData[EMAIL_TEMPLATE_BODY_ID], - template_subject: formData[EMAIL_TEMPLATE_SUBJECT_KEY], - template_greeting: formData[EMAIL_TEMPLATE_GREETING_ID], - template_closing: formData[EMAIL_TEMPLATE_CLOSING_ID], - ...(features.FILE_ATTACHMENT && { - template_files: formData[EMAIL_TEMPLATE_FILES_ID], - }), - enable_nudge_emails: formData[EMAIL_TEMPLATE_NUDGE_EMAIL_ID], - notify_learners: notify, - }; - // If the enterprise has a learner portal, we should direct users to it in our assignment email - if (enableLearnerPortal && configuration.ENTERPRISE_LEARNER_PORTAL_URL) { - options.base_enterprise_url = `${configuration.ENTERPRISE_LEARNER_PORTAL_URL}/${enterpriseSlug}`; - } - - if (formData['template-id']) { - options.template_id = formData['template-id']; - } - - const hasTextAreaEmails = !!formData[EMAIL_ADDRESS_TEXT_FORM_DATA]; - const emails = hasTextAreaEmails ? formData[EMAIL_ADDRESS_TEXT_FORM_DATA].split(/\r\n|\n/) : formData[EMAIL_ADDRESS_CSV_FORM_DATA]; - const { validEmails } = this.validateEmailAddresses(emails, !hasTextAreaEmails); - - if (isBulkAssign) { - // Only includes `codes` in `options` if not all codes are selected. - if (!hasAllCodesSelected) { - options.codes = selectedCodes.map(selectedCode => selectedCode.code); - } - } else { - options.codes = [code.code]; - } - - let pendingEnterpriseUserData; - if (validEmails.length) { - pendingEnterpriseUserData = validEmails.map((email) => ({ - user_email: email, - enterprise_customer: enterpriseUuid, - })); - } else { - pendingEnterpriseUserData = { - user_email: formData['email-address'], - enterprise_customer: enterpriseUuid, - }; - } - const assignmentEmails = isBulkAssign ? validEmails : [formData['email-address']]; - options.users = this.usersEmail(assignmentEmails); - - return createPendingEnterpriseUsers(pendingEnterpriseUserData, enterpriseUuid) - .then(() => sendCodeAssignment(couponId, options)) - .then((response) => { - this.props.onSuccess(response); - }) - .catch((error) => { - const { response, message } = error; - const nonFieldErrors = response && response.data && response.data.non_field_errors; - - let errors = [message]; - - if (nonFieldErrors) { - errors = [errors, ...nonFieldErrors]; - } - - throw new SubmissionError({ - _error: errors, - }); - }); - } + }, [submitSucceeded, submitFailed, error, mode, onClose]); - setMode(mode) { - this.setState({ mode }); - } + useEffect(() => () => { + setEmailAddress('', MODAL_TYPES.assign); + }, [setEmailAddress]); - setNotify() { - this.setState(prevState => ({ notify: !prevState.notify })); - } + const handleModeSet = useCallback((newMode) => { + setMode(newMode); + }, []); - getNumberOfSelectedCodes() { - const { - data: { - selectedCodes, - hasAllCodesSelected, - }, - couponDetailsTable: { data: tableData }, - } = this.props; + const handleNotifyToggle = useCallback(() => { + setNotify(prevNotify => !prevNotify); + }, []); + const getNumberOfSelectedCodes = useCallback(() => { const numberOfSelectedCodes = selectedCodes ? selectedCodes.length : 0; + return hasAllCodesSelected ? couponDetailsTable?.data?.count : numberOfSelectedCodes; + }, [selectedCodes, hasAllCodesSelected, couponDetailsTable]); - return hasAllCodesSelected ? tableData.count : numberOfSelectedCodes; - } - - usersEmail(emails) { + const usersEmail = useCallback((emails) => { const users = []; emails.forEach((email) => { users.push({ @@ -202,9 +108,9 @@ export class BaseCodeAssignmentModal extends React.Component { }); }); return users; - } + }, []); - validateEmailAddresses(emails, isCSV = false) { + const validateEmailAddresses = useCallback((emails, isCSV = false) => { let learnerEmails = emails; const result = { validEmails: [], @@ -233,23 +139,23 @@ export class BaseCodeAssignmentModal extends React.Component { } }); return result; - } + }, []); - validateBulkAssign(formData) { - const { data: { unassignedCodes, couponType } } = this.props; + const validateBulkAssign = useCallback((formData) => { + const { unassignedCodes, couponType } = data; const textAreaEmails = formData[EMAIL_ADDRESS_TEXT_FORM_DATA] && formData[EMAIL_ADDRESS_TEXT_FORM_DATA].split(/\r\n|\n/); const csvEmails = formData[EMAIL_ADDRESS_CSV_FORM_DATA]; const { validEmails: validTextAreaEmails, invalidEmailIndices: invalidTextAreaEmails, - } = this.validateEmailAddresses(textAreaEmails); + } = validateEmailAddresses(textAreaEmails); const { validEmails: validCsvEmails, invalidEmailIndices: invalidCsvEmails, - } = this.validateEmailAddresses(csvEmails, true); + } = validateEmailAddresses(csvEmails, true); - const numberOfSelectedCodes = this.getNumberOfSelectedCodes(); + const numberOfSelectedCodes = getNumberOfSelectedCodes(); const shouldValidateSelectedCodes = ![ONCE_PER_CUSTOMER, MULTI_USE].includes(couponType); const errors = getErrors({ @@ -264,9 +170,9 @@ export class BaseCodeAssignmentModal extends React.Component { /* eslint-enable no-underscore-dangle */ return errors; - } + }, [data, validateEmailAddresses, getNumberOfSelectedCodes]); - validateIndividualAssign(formData) { + const validateIndividualAssign = useCallback((formData) => { const inputKey = 'email-address'; const emailAddress = formData[inputKey]; @@ -287,17 +193,16 @@ export class BaseCodeAssignmentModal extends React.Component { /* eslint-enable no-underscore-dangle */ return errors; - } + }, []); - validateFormData(formData) { - const { isBulkAssign } = this.props; + const validateFormData = useCallback((formData) => { const emailTemplateKey = EMAIL_TEMPLATE_BODY_ID; let errors; if (isBulkAssign) { - errors = this.validateBulkAssign(formData); + errors = validateBulkAssign(formData); } else { - errors = this.validateIndividualAssign(formData); + errors = validateIndividualAssign(formData); } /* eslint-disable no-underscore-dangle */ @@ -314,41 +219,122 @@ export class BaseCodeAssignmentModal extends React.Component { throw new SubmissionError(errors); } /* eslint-enable no-underscore-dangle */ - } + }, [isBulkAssign, validateBulkAssign, validateIndividualAssign]); - hasBulkAssignData() { - const { data } = this.props; - return ['unassignedCodes', 'selectedCodes'].every(key => key in data); - } + const handleModalSubmit = useCallback((formData) => { + handleModeSet(MODAL_TYPES.assign); - hasIndividualAssignData() { - const { data } = this.props; - return ['code', 'remainingUses'].every(key => key in data); - } + // Validate form data + validateFormData(formData); + // Configure the options to send to the assignment API endpoint + const options = { + template: formData[EMAIL_TEMPLATE_BODY_ID], + template_subject: formData[EMAIL_TEMPLATE_SUBJECT_KEY], + template_greeting: formData[EMAIL_TEMPLATE_GREETING_ID], + template_closing: formData[EMAIL_TEMPLATE_CLOSING_ID], + ...(features.FILE_ATTACHMENT && { + template_files: formData[EMAIL_TEMPLATE_FILES_ID], + }), + enable_nudge_emails: formData[EMAIL_TEMPLATE_NUDGE_EMAIL_ID], + notify_learners: notify, + }; + // If the enterprise has a learner portal, we should direct users to it in our assignment email + if (enableLearnerPortal && configuration.ENTERPRISE_LEARNER_PORTAL_URL) { + options.base_enterprise_url = `${configuration.ENTERPRISE_LEARNER_PORTAL_URL}/${enterpriseSlug}`; + } - renderBody() { - const { - data, - isBulkAssign, - submitFailed, - error, - intl: { formatMessage }, - } = this.props; + if (formData['template-id']) { + options.template_id = formData['template-id']; + } - const { mode, notify } = this.state; - const numberOfSelectedCodes = this.getNumberOfSelectedCodes(); + const hasTextAreaEmails = !!formData[EMAIL_ADDRESS_TEXT_FORM_DATA]; + const emails = hasTextAreaEmails ? formData[EMAIL_ADDRESS_TEXT_FORM_DATA].split(/\r\n|\n/) : formData[EMAIL_ADDRESS_CSV_FORM_DATA]; + const { validEmails } = validateEmailAddresses(emails, !hasTextAreaEmails); + + if (isBulkAssign) { + // Only includes `codes` in `options` if not all codes are selected. + if (!hasAllCodesSelected) { + options.codes = selectedCodes.map(selectedCode => selectedCode.code); + } + } else { + options.codes = [code.code]; + } + + let pendingEnterpriseUserData; + if (validEmails.length) { + pendingEnterpriseUserData = validEmails.map((email) => ({ + user_email: email, + enterprise_customer: enterpriseUuid, + })); + } else { + pendingEnterpriseUserData = { + user_email: formData['email-address'], + enterprise_customer: enterpriseUuid, + }; + } + const assignmentEmails = isBulkAssign ? validEmails : [formData['email-address']]; + options.users = usersEmail(assignmentEmails); + + return createPendingEnterpriseUsers(pendingEnterpriseUserData, enterpriseUuid) + .then(() => sendCodeAssignment(couponId, options)) + .then((response) => { + props.onSuccess(response); + }) + .catch((errorResponse) => { + const { response, message } = errorResponse; + const nonFieldErrors = response && response.data && response.data.non_field_errors; + + let errors = [message]; + + if (nonFieldErrors) { + errors = [errors, ...nonFieldErrors]; + } + + throw new SubmissionError({ + _error: errors, + }); + }); + }, [ + handleModeSet, + validateFormData, + notify, + enableLearnerPortal, + enterpriseSlug, + validateEmailAddresses, + isBulkAssign, + hasAllCodesSelected, + selectedCodes, + code, + usersEmail, + createPendingEnterpriseUsers, + enterpriseUuid, + sendCodeAssignment, + couponId, + props, + ]); + + const hasBulkAssignData = useCallback(() => ( + ['unassignedCodes', 'selectedCodes'].every(key => key in data) + ), [data]); + + const hasIndividualAssignData = useCallback(() => ( + ['code', 'remainingUses'].every(key => key in data) + ), [data]); + + const renderBody = () => { + const numberOfSelectedCodes = getNumberOfSelectedCodes(); return ( <> - {submitFailed && } + {submitFailed && }
- {isBulkAssign && this.hasBulkAssignData() && ( + {isBulkAssign && hasBulkAssignData() && ( <>

Unassigned codes: {data.unassignedCodes}

{numberOfSelectedCodes > 0 &&

{displaySelectedCodes(numberOfSelectedCodes)}

} )} - {!isBulkAssign && this.hasIndividualAssignData() && ( + {!isBulkAssign && hasIndividualAssignData() && ( <>

{displayCode(data.code.code)}

Remaining Uses: {data.remainingUses}

@@ -362,7 +348,7 @@ export class BaseCodeAssignmentModal extends React.Component {
Notify learners by email @@ -371,75 +357,61 @@ export class BaseCodeAssignmentModal extends React.Component { )}
); - } - - renderTitle() { - return this.props.title; - } - - render() { - const { - isBulkAssign, - onClose, - submitting, - handleSubmit, - } = this.props; - const { - mode, - } = this.state; - - return ( - - - - {this.renderTitle()} - - - - {this.renderBody()} - - - - - Cancel - - , - , - - - - ); - } -} + }; + + const renderTitle = () => title; + + return ( + + + + {renderTitle()} + + + + {renderBody()} + + + + + Cancel + + , + , + + + + ); +}; BaseCodeAssignmentModal.defaultProps = { error: null, @@ -486,11 +458,8 @@ BaseCodeAssignmentModal.propTypes = { unassignedCodes: PropTypes.number, remainingUses: PropTypes.number, }), - - // injected - intl: intlShape.isRequired, }; export default reduxForm({ form: 'code-assignment-modal-form', -})(injectIntl(BaseCodeAssignmentModal)); +})(BaseCodeAssignmentModal); diff --git a/src/components/CodeManagement/ManageCodesTab.jsx b/src/components/CodeManagement/ManageCodesTab.jsx index cad970769e..280d2a17c6 100644 --- a/src/components/CodeManagement/ManageCodesTab.jsx +++ b/src/components/CodeManagement/ManageCodesTab.jsx @@ -1,4 +1,6 @@ -import React from 'react'; +import React, { + useState, useEffect, useRef, useContext, useCallback, +} from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { connect } from 'react-redux'; @@ -10,7 +12,7 @@ import { CheckCircle, Info, Plus, SpinnerIcon, WarningFilled, } from '@openedx/paragon/icons'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import SearchBar from '../SearchBar'; import CodeSearchResults from '../CodeSearchResults'; import LoadingMessage from '../LoadingMessage'; @@ -24,192 +26,171 @@ import { SUPPORTED_SUBSIDY_TYPES } from '../../data/constants/subsidyRequests'; import { withLocation, withNavigate } from '../../hoc'; import CodeDeprecationAlert from '../CodeDeprecationAlert/CodeDeprecationAlert'; -class ManageCodesTab extends React.Component { - constructor(props) { - super(props); +const ManageCodesTab = ({ + fetchCouponOrders: fetchCouponOrdersProp, + clearCouponOrders: clearCouponOrdersProp, + location, + navigate, + enterpriseId, + enterpriseSlug, + coupons, + loading, + error, +}) => { + const intl = useIntl(); + const { subsidyRequestConfiguration } = useContext(SubsidyRequestsContext); + const couponRefs = useRef([]); + const [hasRequestedCodes, setHasRequestedCodes] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + // Helper functions with useCallback to avoid dependency issues + const paginateCouponOrders = useCallback((pageNumber) => { + const page = pageNumber ? parseInt(pageNumber, 10) : 1; + fetchCouponOrdersProp({ page }); + }, [fetchCouponOrdersProp]); + + const getCouponRefs = useCallback(() => couponRefs.current.filter(coupon => coupon), []); + + const setCouponOpacity = useCallback((couponId) => { + const couponRefsArray = getCouponRefs(); + + if (couponId) { + couponRefsArray.forEach((coupon) => { + const { data: { id } } = coupon.props; + if (id !== parseInt(couponId, 10)) { + coupon.setCouponOpacity(true); + } + }); + } else { + couponRefsArray.forEach((coupon) => { + coupon.setCouponOpacity(false); + }); + } + }, [getCouponRefs]); + + const removeQueryParams = useCallback((keys) => { + const queryParams = {}; + keys.forEach((key) => { + queryParams[key] = undefined; + }); + updateUrl(navigate, location.pathname, queryParams); + }, [navigate, location.pathname]); - this.couponRefs = []; - this.state = { - hasRequestedCodes: false, - searchQuery: '', + const hasCouponData = useCallback((couponsData) => { + if (!couponsData) { + return false; + } + const { results } = couponsData; + return results && results.length > 0; + }, []); + + const handleRefreshData = useCallback(() => { + paginateCouponOrders(1); + removeQueryParams(['coupon_id', 'page', 'overview_page']); + setSearchQuery(''); + }, [paginateCouponOrders, removeQueryParams]); + + const handleCouponExpand = useCallback((selectedIndex) => { + const couponsArray = getCouponRefs(); + const selectedCoupon = couponsArray[selectedIndex]; + const couponId = selectedCoupon.props.data.id; + const queryParams = { + coupon_id: couponId, }; + updateUrl(navigate, location.pathname, queryParams); + setCouponOpacity(couponId); + setSearchQuery(''); + }, [getCouponRefs, navigate, location.pathname, setCouponOpacity]); - this.handleRefreshData = this.handleRefreshData.bind(this); - } + const handleCouponCollapse = useCallback(() => { + setCouponOpacity(); + removeQueryParams(['coupon_id', 'page']); + }, [setCouponOpacity, removeQueryParams]); - componentDidMount() { - const { enterpriseId, location, navigate } = this.props; + // Effects + useEffect(() => { const queryParams = new URLSearchParams(location.search); if (enterpriseId) { - this.paginateCouponOrders(queryParams.get('overview_page') || 1); + paginateCouponOrders(queryParams.get('overview_page') || 1); } if (location.state && location.state.hasRequestedCodes) { - this.setState({ // eslint-disable-line react/no-did-mount-set-state - hasRequestedCodes: location.state.hasRequestedCodes, - }); - + setHasRequestedCodes(location.state.hasRequestedCodes); navigate(location.pathname, { state: {}, replace: true }); } + }, [enterpriseId, location.search, location.state, location.pathname, navigate, paginateCouponOrders]); - // To fetch active email templates for assign, remind, and revoke - // There can only exist one active email template against each action - // Which is why we are passing active query param - } - - componentDidUpdate(prevProps) { - const { - coupons, - enterpriseId, - location, - } = this.props; - + useEffect(() => { const queryParams = new URLSearchParams(location.search); - const prevQueryParams = new URLSearchParams(prevProps.location.search); const couponId = queryParams.get('coupon_id'); - if (enterpriseId && enterpriseId !== prevProps.enterpriseId) { - this.paginateCouponOrders(queryParams.get('overview_page')); - } - - if (queryParams.get('overview_page') !== prevQueryParams.get('overview_page')) { - this.paginateCouponOrders(queryParams.get('overview_page')); + if (enterpriseId) { + paginateCouponOrders(queryParams.get('overview_page')); } // If the specified coupon id doesn't exist in the coupons returned by the API, // remove the coupon id from the URL. - if (couponId && coupons && coupons !== prevProps.coupons) { + if (couponId && coupons) { const couponWithIdExists = coupons.results.find(( coupon => coupon.id === parseInt(couponId, 10) )); if (!couponWithIdExists) { - this.removeQueryParams(['coupon_id', 'page']); + removeQueryParams(['coupon_id', 'page']); } } - if (queryParams !== prevQueryParams) { - this.setCouponOpacity(couponId); - } - } - - componentWillUnmount() { - this.props.clearCouponOrders(); - } - - handleRefreshData() { - this.paginateCouponOrders(1); - this.removeQueryParams(['coupon_id', 'page', 'overview_page']); - this.setState({ searchQuery: '' }); - } - - handleCouponExpand(selectedIndex) { - const coupons = this.getCouponRefs(); - const selectedCoupon = coupons[selectedIndex]; - const couponId = selectedCoupon.props.data.id; - const queryParams = { - coupon_id: couponId, - }; - const { navigate, location } = this.props; - updateUrl(navigate, location.pathname, queryParams); - this.setCouponOpacity(couponId); - this.setState({ searchQuery: '' }); - } - - handleCouponCollapse() { - this.setCouponOpacity(); - this.removeQueryParams(['coupon_id', 'page']); - } - - getCouponRefs() { - return this.couponRefs.filter(coupon => coupon); - } - - setCouponOpacity(couponId) { - const couponRefs = this.getCouponRefs(); - - if (couponId) { - couponRefs.forEach((coupon) => { - const { data: { id } } = coupon.props; - if (id !== parseInt(couponId, 10)) { - coupon.setCouponOpacity(true); - } - }); - } else { - couponRefs.forEach((coupon) => { - coupon.setCouponOpacity(false); - }); - } - } - - removeQueryParams(keys) { - const queryParams = {}; - keys.forEach((key) => { - queryParams[key] = undefined; - }); - const { navigate, location } = this.props; - updateUrl(navigate, location.pathname, queryParams); - } - - paginateCouponOrders(pageNumber) { - const page = pageNumber ? parseInt(pageNumber, 10) : 1; - this.props.fetchCouponOrders({ page }); - } - - hasCouponData(coupons) { - if (!coupons) { - return false; - } - const { results } = coupons; - return results && results.length > 0; - } - - renderLoadingMessage() { - return ; - } - - renderErrorMessage() { - return ( - - - - -

- -

-
- ); - } - - renderCoupons() { - const { coupons, location } = this.props; + setCouponOpacity(couponId); + }, [enterpriseId, location.search, coupons, paginateCouponOrders, removeQueryParams, setCouponOpacity]); + + useEffect(() => () => { + clearCouponOrdersProp(); + }, [clearCouponOrdersProp]); + + // Render methods + const renderLoadingMessage = () => ( + + ); + + const renderErrorMessage = () => ( + + + + +

+ +

+
+ ); + + const renderCoupons = () => { const queryParams = new URLSearchParams(location.search); return ( <> {coupons.results.map((coupon, index) => ( { this.couponRefs[index] = node; }} + ref={(node) => { couponRefs.current[index] = node; }} key={coupon.id} data={coupon} isExpanded={coupon.id === parseInt(queryParams.get('coupon_id'), 10)} - onExpand={() => this.handleCouponExpand(index)} - onCollapse={() => this.handleCouponCollapse()} + onExpand={() => handleCouponExpand(index)} + onCollapse={() => handleCouponCollapse()} /> ))}
updateUrl(this.props.navigate, this.props.location.pathname, { + onPageSelect={page => updateUrl(navigate, location.pathname, { coupon_id: undefined, page: undefined, overview_page: page !== 1 ? page : undefined, @@ -221,155 +202,142 @@ class ManageCodesTab extends React.Component {
); - } - - renderRequestCodesSuccessMessage() { - return ( - this.setState({ hasRequestedCodes: false })} - variant="success" - icon={CheckCircle} - dismissible - > - - - -

- ( + setHasRequestedCodes(false)} + variant="success" + icon={CheckCircle} + dismissible + > + + + +

+ +

+
+ ); + + const renderEmptyDataMessage = () => ( + + There are no results. + + ); + + // Main render + // don't show alert if the enterprise already has subsidy requests enabled + const isBrowseAndRequestFeatureAlertShown = subsidyRequestConfiguration?.subsidyType + === SUPPORTED_SUBSIDY_TYPES.coupon && !subsidyRequestConfiguration?.subsidyRequestsEnabled; + + const hasSearchQuery = !!searchQuery; + + return ( + <> + {renderRequestCodesSuccessMessage()} + + {isBrowseAndRequestFeatureAlertShown && } +
+
+

+ +

+
+
+ { + setSearchQuery(query); + removeQueryParams(['coupon_id', 'page']); + }} + onClear={() => { + setSearchQuery(''); + removeQueryParams(['page']); + }} + value={searchQuery} + inputProps={{ 'data-hj-suppress': true }} /> -

- - ); - } - - renderEmptyDataMessage() { - return ( - - There are no results. - - ); - } - - render() { - const { subsidyRequestConfiguration } = this.context; - // don't show alert if the enterprise already has subsidy requests enabled - const isBrowseAndRequestFeatureAlertShown = subsidyRequestConfiguration?.subsidyType - === SUPPORTED_SUBSIDY_TYPES.coupon && !subsidyRequestConfiguration?.subsidyRequestsEnabled; - - const { - coupons, - error, - loading, - enterpriseSlug, - intl, - } = this.props; - const { searchQuery } = this.state; - const hasSearchQuery = !!searchQuery; - return ( - <> - {this.renderRequestCodesSuccessMessage()} - - {isBrowseAndRequestFeatureAlertShown && } -
-
-

+

+
+
-
- { - this.setState({ searchQuery: query }); - this.removeQueryParams(['coupon_id', 'page']); - }} - onClear={() => { - this.setState({ searchQuery: '' }); - this.removeQueryParams(['page']); - }} - value={searchQuery} - inputProps={{ 'data-hj-suppress': true }} - /> -
-
- - - <> - - - - -
-
-
-
+ + - { - this.setState({ searchQuery: '' }); - }} - /> -
+ <> + + + +
-
-
- {error && this.renderErrorMessage()} - {loading && this.renderLoadingMessage()} - {!loading && !error && !this.hasCouponData(coupons) - && this.renderEmptyDataMessage()} - {!loading && !error && this.hasCouponData(coupons) && this.renderCoupons()} -
+
+
+
+ { + setSearchQuery(''); + }} + />
- - ); - } -} +
+
+
+ {error && renderErrorMessage()} + {loading && renderLoadingMessage()} + {!loading && !error && !hasCouponData(coupons) + && renderEmptyDataMessage()} + {!loading && !error && hasCouponData(coupons) && renderCoupons()} +
+
+ + ); +}; ManageCodesTab.defaultProps = { enterpriseId: null, @@ -399,8 +367,6 @@ ManageCodesTab.propTypes = { }), loading: PropTypes.bool, error: PropTypes.instanceOf(Error), - // injected - intl: intlShape.isRequired, }; const mapStateToProps = state => ({ @@ -420,6 +386,4 @@ const mapDispatchToProps = dispatch => ({ }, }); -ManageCodesTab.contextType = SubsidyRequestsContext; - -export default connect(mapStateToProps, mapDispatchToProps)(withNavigate(withLocation((injectIntl(ManageCodesTab))))); +export default connect(mapStateToProps, mapDispatchToProps)(withNavigate(withLocation(ManageCodesTab))); diff --git a/src/components/CodeReminderModal/index.jsx b/src/components/CodeReminderModal/index.jsx index c16000ae76..754a2e247e 100644 --- a/src/components/CodeReminderModal/index.jsx +++ b/src/components/CodeReminderModal/index.jsx @@ -1,10 +1,10 @@ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { reduxForm, SubmissionError } from 'redux-form'; import { Button, ModalDialog, ActionRow, Spinner, } from '@openedx/paragon'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import SaveTemplateButton from '../../containers/SaveTemplateButton'; @@ -25,55 +25,44 @@ const ERROR_MESSAGE_TITLES = { [MODAL_TYPES.save]: 'Could not save template', }; -export class BaseCodeReminderModal extends React.Component { - constructor(props) { - super(props); - - this.errorMessageRef = React.createRef(); - - this.state = { - mode: REMIND_MODE, - }; - - this.setMode = this.setMode.bind(this); - this.handleModalSubmit = this.handleModalSubmit.bind(this); - this.getNumberOfSelectedCodes = this.getNumberOfSelectedCodes.bind(this); - } - - componentDidUpdate(prevProps) { - const { - submitFailed, - submitSucceeded, - onClose, - error, - } = this.props; - const { - mode, - } = this.state; - - const errorMessageRef = this.errorMessageRef && this.errorMessageRef.current; - - if (mode === REMIND_MODE && submitSucceeded && submitSucceeded !== prevProps.submitSucceeded) { +export const BaseCodeReminderModal = ({ + submitFailed, + submitSucceeded, + onClose, + error, + couponId, + isBulkRemind, + selectedToggle, + data, + sendCodeReminder, + enableLearnerPortal, + enterpriseSlug, + title, + submitting, + handleSubmit, + couponDetailsTable, + onSuccess, +}) => { + const { formatMessage } = useIntl(); + + const [mode, setMode] = useState(REMIND_MODE); + const errorMessageRef = useRef(null); + + useEffect(() => { + const errorMessageElement = errorMessageRef?.current; + + if (mode === REMIND_MODE && submitSucceeded) { onClose(); } - if (submitFailed && error !== prevProps.error && errorMessageRef) { - // When there is an new error, focus on the error message status alert - errorMessageRef.focus(); + if (submitFailed && error && errorMessageElement) { + // When there is a new error, focus on the error message status alert + errorMessageElement.focus(); } - } + }, [submitFailed, submitSucceeded, onClose, error, mode]); - handleModalSubmit(formData) { - const { - couponId, - isBulkRemind, - selectedToggle, - data, - sendCodeReminder, - enableLearnerPortal, - enterpriseSlug, - } = this.props; - this.setMode(REMIND_MODE); + const handleModalSubmit = (formData) => { + setMode(REMIND_MODE); // Validate form data const emailTemplateKey = 'email-template-body'; @@ -120,21 +109,22 @@ export class BaseCodeReminderModal extends React.Component { return sendCodeReminder(couponId, options) .then((response) => { - this.props.onSuccess(response); + onSuccess(response); }) - .catch((error) => { + .catch((err) => { throw new SubmissionError({ - _error: [error.message], + _error: [err.message], }); }); /* eslint-enable no-underscore-dangle */ - } + }; - getNumberOfSelectedCodes() { + const getNumberOfSelectedCodes = () => { const { - data: { selectedCodes }, - couponDetailsTable: { data: tableData }, - } = this.props; + selectedCodes, + } = data; + const tableData = couponDetailsTable?.data; + let numberOfSelectedCodes = 0; if (selectedCodes && selectedCodes.length) { numberOfSelectedCodes = selectedCodes.length; @@ -142,28 +132,12 @@ export class BaseCodeReminderModal extends React.Component { numberOfSelectedCodes = tableData.count; } return numberOfSelectedCodes; - } + }; - setMode(mode) { - this.setState({ mode }); - } + const hasIndividualRemindData = () => ['code', 'email'].every(key => key in data); - hasIndividualRemindData() { - const { data } = this.props; - return ['code', 'email'].every(key => key in data); - } - - renderBody() { - const { - data, - isBulkRemind, - intl: { formatMessage }, - submitFailed, - error, - } = this.props; - const { mode } = this.state; - - const numberOfSelectedCodes = this.getNumberOfSelectedCodes(); + const renderBody = () => { + const numberOfSelectedCodes = getNumberOfSelectedCodes(); const emailTemplateFields = getTemplateEmailFields(formatMessage); const reminderEmailTemplateFields = { ...emailTemplateFields, @@ -178,12 +152,12 @@ export class BaseCodeReminderModal extends React.Component { )} @@ -193,69 +167,56 @@ export class BaseCodeReminderModal extends React.Component { /> ); - } - - renderTitle() { - return this.props.title; - } - - render() { - const { - onClose, - submitting, - handleSubmit, - } = this.props; - const { - mode, - } = this.state; - - return ( - - - - {this.renderTitle()} - - - - {this.renderBody()} - - - - - Cancel - - , - , - - - - ); - } -} + }; + + const renderTitle = () => title; + + return ( + + + + {renderTitle()} + + + + {renderBody()} + + + + + Cancel + + , + , + + + + ); +}; BaseCodeReminderModal.defaultProps = { error: null, @@ -296,10 +257,8 @@ BaseCodeReminderModal.propTypes = { code: PropTypes.string, email: PropTypes.string, }), - // injected - intl: intlShape.isRequired, }; export default reduxForm({ form: 'code-reminder-modal-form', -})(injectIntl(BaseCodeReminderModal)); +})(BaseCodeReminderModal); diff --git a/src/components/Footer/index.jsx b/src/components/Footer/index.jsx index e0e3b03719..e6348aa501 100644 --- a/src/components/Footer/index.jsx +++ b/src/components/Footer/index.jsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { configuration } from '../../config'; @@ -9,92 +9,70 @@ import Img from '../Img'; import messages from './messages'; import './Footer.scss'; -class Footer extends React.Component { - constructor(props) { - super(props); - this.state = { - enterpriseLogoNotFound: false, - }; - } +const Footer = ({ enterpriseLogo = null, enterpriseSlug = null, enterpriseName = null }) => { + const [enterpriseLogoNotFound, setEnterpriseLogoNotFound] = useState(false); - componentDidUpdate(prevProps) { - const { enterpriseLogo } = this.props; - if (enterpriseLogo && enterpriseLogo !== prevProps.enterpriseLogo) { - this.setState({ // eslint-disable-line react/no-did-update-set-state - enterpriseLogoNotFound: false, - }); - } - } + const { formatMessage } = useIntl(); - renderEnterpriseLogo() { - const { enterpriseLogo, enterpriseSlug, enterpriseName } = this.props; - return ( - - {`${enterpriseName} this.setState({ enterpriseLogoNotFound: true })} - /> - - ); - } + useEffect(() => { + if (enterpriseLogo) { + setEnterpriseLogoNotFound(false); + } + }, [enterpriseLogo]); - render() { - const { enterpriseLogoNotFound } = this.state; - const { enterpriseLogo } = this.props; - const { formatMessage } = this.props.intl; + const renderEnterpriseLogo = () => ( + + {`${enterpriseName} setEnterpriseLogoNotFound(true)} + /> + + ); - return ( - + ); +}; Footer.propTypes = { enterpriseName: PropTypes.string, enterpriseSlug: PropTypes.string, enterpriseLogo: PropTypes.string, - intl: intlShape.isRequired, // injected by injectIntl -}; - -Footer.defaultProps = { - enterpriseName: null, - enterpriseSlug: null, - enterpriseLogo: null, }; -export default injectIntl(Footer); +export default Footer; diff --git a/src/components/LearnerActivityTable/index.jsx b/src/components/LearnerActivityTable/index.jsx index e591d77627..0e406a5ced 100644 --- a/src/components/LearnerActivityTable/index.jsx +++ b/src/components/LearnerActivityTable/index.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import TableContainer from '../../containers/TableContainer'; import { @@ -9,9 +9,9 @@ import { } from '../../utils'; import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiService'; -class LearnerActivityTable extends React.Component { - getTableColumns() { - const { activity, intl } = this.props; +const LearnerActivityTable = ({ activity, id }) => { + const intl = useIntl(); + const getTableColumns = () => { const tableColumns = [ { label: intl.formatMessage({ @@ -100,53 +100,48 @@ class LearnerActivityTable extends React.Component { return tableColumns.filter(column => column.key !== 'passed_date'); } return tableColumns; - } + }; - formatTableData = enrollments => enrollments.map(enrollment => ({ + const formatTableData = enrollments => enrollments.map(enrollment => ({ ...enrollment, user_email: {enrollment.user_email}, - last_activity_date: i18nFormatTimestamp({ intl: this.props.intl, timestamp: enrollment.last_activity_date }), - course_start_date: i18nFormatTimestamp({ intl: this.props.intl, timestamp: enrollment.course_start_date }), - course_end_date: i18nFormatTimestamp({ intl: this.props.intl, timestamp: enrollment.course_end_date }), + last_activity_date: i18nFormatTimestamp({ intl, timestamp: enrollment.last_activity_date }), + course_start_date: i18nFormatTimestamp({ intl, timestamp: enrollment.course_start_date }), + course_end_date: i18nFormatTimestamp({ intl, timestamp: enrollment.course_end_date }), enrollment_date: i18nFormatTimestamp({ - intl: this.props.intl, timestamp: enrollment.enrollment_date, + intl, timestamp: enrollment.enrollment_date, }), - passed_date: i18nFormatPassedTimestamp({ intl: this.props.intl, timestamp: enrollment.passed_date }), + passed_date: i18nFormatPassedTimestamp({ intl, timestamp: enrollment.passed_date }), user_account_creation_date: i18nFormatTimestamp({ - intl: this.props.intl, timestamp: enrollment.user_account_creation_date, + intl, timestamp: enrollment.user_account_creation_date, }), - progress_status: i18nFormatProgressStatus({ intl: this.props.intl, progressStatus: enrollment.progress_status }), + progress_status: i18nFormatProgressStatus({ intl, progressStatus: enrollment.progress_status }), course_list_price: enrollment.course_list_price ? `$${enrollment.course_list_price}` : '', current_grade: formatPercentage({ decimal: enrollment.current_grade }), })); - render() { - const { activity, id } = this.props; - return ( - EnterpriseDataApiService.fetchCourseEnrollments( - enterpriseId, - { - learnerActivity: activity, - ...options, - }, - )} - columns={this.getTableColumns()} - formatData={this.formatTableData} - tableSortable - /> - ); - } -} + return ( + EnterpriseDataApiService.fetchCourseEnrollments( + enterpriseId, + { + learnerActivity: activity, + ...options, + }, + )} + columns={getTableColumns()} + formatData={formatTableData} + tableSortable + /> + ); +}; LearnerActivityTable.propTypes = { id: PropTypes.string.isRequired, activity: PropTypes.string.isRequired, - // injected - intl: intlShape.isRequired, }; -export default injectIntl(LearnerActivityTable); +export default LearnerActivityTable; From fe3d01490f51831bc8859f5835035d732f9965a2 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Thu, 11 Sep 2025 16:45:39 -0600 Subject: [PATCH 2/2] test: improve coverage, verify clear --- .../tests/ManageCodesTab.test.jsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/components/CodeManagement/tests/ManageCodesTab.test.jsx b/src/components/CodeManagement/tests/ManageCodesTab.test.jsx index 8c98f9ccaf..1f3ef71b3d 100644 --- a/src/components/CodeManagement/tests/ManageCodesTab.test.jsx +++ b/src/components/CodeManagement/tests/ManageCodesTab.test.jsx @@ -101,6 +101,46 @@ const sampleCouponData = { }; describe('ManageCodesTabWrapper', () => { + describe('search functionality', () => { + it('clears search query and removes page parameter when search bar clear button is clicked', async () => { + const store = mockStore({ + ...initialState, + coupons: { + ...initialState.coupons, + data: { + count: 1, + results: [sampleCouponData], + }, + }, + }); + const user = userEvent.setup(); + + const { rerender } = render(); + + const searchInput = screen.getByPlaceholderText('Search by email or code...'); + await user.type(searchInput, 'test search'); + + const clearButton = await screen.findByRole('button', { name: /clear/i }); + await user.click(clearButton); + + expect(searchInput.value).toBe(''); + + rerender(); + }); + }); + describe('renders', () => { it('renders empty results correctly', () => { const { container } = render();