Skip to content

Commit

Permalink
Merge branch 'master' into kiram15/ENT-8657
Browse files Browse the repository at this point in the history
  • Loading branch information
kiram15 authored Mar 20, 2024
2 parents ac37c5d + 37e3151 commit 0c1c27f
Show file tree
Hide file tree
Showing 28 changed files with 740 additions and 32 deletions.
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Stack, Skeleton } from '@edx/paragon';
import { Stack, Skeleton, useToggle } from '@edx/paragon';

import BudgetDetailRedemptions from './BudgetDetailRedemptions';
import BudgetDetailAssignments from './BudgetDetailAssignments';
import { useBudgetDetailActivityOverview, useBudgetId, useSubsidyAccessPolicy } from './data';
import NoAssignableBudgetActivity from './empty-state/NoAssignableBudgetActivity';
import NoBnEBudgetActivity from './empty-state/NoBnEBudgetActivity';
import InviteMembersModalWrapper from './invite-modal/InviteMembersModalWrapper';

const BudgetDetailActivityTabContents = ({ enterpriseUUID, enterpriseFeatures }) => {
const [inviteModalIsOpen, openInviteModal, closeInviteModal] = useToggle(false);
const isTopDownAssignmentEnabled = enterpriseFeatures.topDownAssignmentRealTimeLcm;
const isEnterpriseGroupsEnabled = enterpriseFeatures.enterpriseGroupsV1;
const { subsidyAccessPolicyId } = useBudgetId();

const { enterpriseOfferId, subsidyAccessPolicyId } = useBudgetId();
const { data: subsidyAccessPolicy } = useSubsidyAccessPolicy(subsidyAccessPolicyId);
const {
isLoading: isBudgetActivityOverviewLoading,
Expand All @@ -37,11 +40,14 @@ const BudgetDetailActivityTabContents = ({ enterpriseUUID, enterpriseFeatures })
const hasSpentTransactions = budgetActivityOverview.spentTransactions?.count > 0;
const hasContentAssignments = budgetActivityOverview.contentAssignments?.count > 0;

// If enterprise groups is turned on, it's learner credit NOT enterprise offers w/ no spend
const renderBnEActivity = isEnterpriseGroupsEnabled && (enterpriseOfferId == null) && !hasSpentTransactions;

if (!isTopDownAssignmentEnabled || !subsidyAccessPolicy?.isAssignable) {
return (
<>
{!hasSpentTransactions && isEnterpriseGroupsEnabled && (
<NoBnEBudgetActivity />)}
{renderBnEActivity && (<NoBnEBudgetActivity openInviteModal={openInviteModal} />)}
<InviteMembersModalWrapper isOpen={inviteModalIsOpen} close={closeInviteModal} />
<BudgetDetailRedemptions />
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { Helmet } from 'react-helmet';
import { Container, Toast } from '@edx/paragon';

import Hero from '../Hero';
import { useSuccessfulAssignmentToastContextValue, useSuccessfulCancellationToastContextValue, useSuccessfulReminderToastContextValue } from './data';
import {
useSuccessfulAssignmentToastContextValue,
useSuccessfulCancellationToastContextValue,
useSuccessfulReminderToastContextValue,
} from './data';
import useSuccessfulInvitationToastContextValue from './data/hooks/useSuccessfulInvitationToastContextValue';

const PAGE_TITLE = 'Learner Credit Management';

Expand Down Expand Up @@ -37,6 +42,7 @@ const BudgetDetailPageWrapper = ({
const successfulAssignmentToast = useSuccessfulAssignmentToastContextValue();
const successfulCancellationToast = useSuccessfulCancellationToastContextValue();
const successfulReminderToast = useSuccessfulReminderToastContextValue();
const successfulInvitationToast = useSuccessfulInvitationToastContextValue();

const {
isSuccessfulAssignmentAllocationToastOpen,
Expand All @@ -56,11 +62,18 @@ const BudgetDetailPageWrapper = ({
closeToastForAssignmentReminder,
} = successfulReminderToast;

const {
isSuccessfulInvitationToastOpen,
successfulInvitationToastMessage,
closeToastForInvitation,
} = successfulInvitationToast;

const values = useMemo(() => ({
successfulAssignmentToast,
successfulCancellationToast,
successfulReminderToast,
}), [successfulAssignmentToast, successfulCancellationToast, successfulReminderToast]);
successfulInvitationToast,
}), [successfulAssignmentToast, successfulCancellationToast, successfulReminderToast, successfulInvitationToast]);

return (
<BudgetDetailPageContext.Provider value={values}>
Expand Down Expand Up @@ -96,6 +109,13 @@ const BudgetDetailPageWrapper = ({
>
{successfulAssignmentReminderToastMessage}
</Toast>

<Toast
onClose={closeToastForInvitation}
show={isSuccessfulInvitationToastOpen}
>
{successfulInvitationToastMessage}
</Toast>
</BudgetDetailPageContext.Provider>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import {
import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils';

import { connect } from 'react-redux';
import BaseCourseCard from './BaseCourseCard';
import BaseCourseCard from '../cards/BaseCourseCard';
import { formatPrice, useBudgetId, useSubsidyAccessPolicy } from '../data';
import AssignmentModalSummary from './AssignmentModalSummary';
import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, isEmailAddressesInputValueValid } from './data';
import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, isAssignEmailAddressesInputValueValid } from '../cards/data';
import AssignmentAllocationHelpCollapsibles from './AssignmentAllocationHelpCollapsibles';
import EVENT_NAMES from '../../../eventTracking';

Expand Down Expand Up @@ -57,7 +57,7 @@ const AssignmentModalContent = ({ enterpriseId, course, onEmailAddressesChange }

// Validate the learner emails emails from user input whenever it changes
useEffect(() => {
const allocationMetadata = isEmailAddressesInputValueValid({
const allocationMetadata = isAssignEmailAddressesInputValueValid({
learnerEmails,
remainingBalance: spendAvailable,
contentPrice,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const AssignmentModalSummary = ({
isValidInput,
learnerEmailsCount,
totalAssignmentCost,
hasEnoughBalanceForAssigment,
hasEnoughBalanceForAssignment,
} = assignmentAllocationMetadata;
const hasLearnerEmails = learnerEmailsCount > 0 && isValidInput;

Expand Down Expand Up @@ -69,12 +69,12 @@ const AssignmentModalSummary = ({
<Card
className={classNames(
'assignment-modal-total-assignment-cost-card rounded-0 shadow-none',
{ invalid: !hasEnoughBalanceForAssigment },
{ invalid: !hasEnoughBalanceForAssignment },
)}
>
<Card.Section className="py-2">
<Stack direction="horizontal" gap={3}>
{!hasEnoughBalanceForAssigment && <Icon className="text-danger" src={Error} />}
{!hasEnoughBalanceForAssignment && <Icon className="text-danger" src={Error} />}
<Stack direction="horizontal" className="justify-space-between flex-grow-1">
<div>Total assignment cost</div>
<div className="ml-auto">{formatPrice(totalAssignmentCost)}</div>
Expand Down Expand Up @@ -102,7 +102,7 @@ AssignmentModalSummary.propTypes = {
isValidInput: PropTypes.bool,
learnerEmailsCount: PropTypes.number,
totalAssignmentCost: PropTypes.number,
hasEnoughBalanceForAssigment: PropTypes.bool,
hasEnoughBalanceForAssignment: PropTypes.bool,
}).isRequired,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from '@edx/paragon';
import { Person } from '@edx/paragon/icons';

import { MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT, hasLearnerEmailsSummaryListTruncation } from './data';
import { MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT, hasLearnerEmailsSummaryListTruncation } from '../cards/data';

const AssignmentModalSummaryLearnerList = ({
course,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import { useToggle } from '@edx/paragon';
import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils';
import { connect } from 'react-redux';
import SystemErrorAlertModal from './assignment-allocation-status-modals/SystemErrorAlertModal';
import ContentNotInCatalogErrorAlertModal from './assignment-allocation-status-modals/ContentNotInCatalogErrorAlertModal';
import NotEnoughBalanceAlertModal from './assignment-allocation-status-modals/NotEnoughBalanceAlertModal';
import SystemErrorAlertModal from '../cards/assignment-allocation-status-modals/SystemErrorAlertModal';
import ContentNotInCatalogErrorAlertModal from '../cards/assignment-allocation-status-modals/ContentNotInCatalogErrorAlertModal';
import NotEnoughBalanceAlertModal from '../cards/assignment-allocation-status-modals/NotEnoughBalanceAlertModal';
import EVENT_NAMES from '../../../eventTracking';

const CreateAllocationErrorAlertModals = ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import React from 'react';
import NewAssignmentModalButton from './NewAssignmentModalButton';
import NewAssignmentModalButton from '../assignment-modal/NewAssignmentModalButton';
import EVENT_NAMES from '../../../eventTracking';
import CARD_TEXT from '../constants';

Expand Down
77 changes: 72 additions & 5 deletions src/components/learner-credit-management/cards/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const hasLearnerEmailsSummaryListTruncation = (learnerEmails) => (
* input, including a validation error when appropriate, and whether the assignment allocation
* should proceed.
*/
export const isEmailAddressesInputValueValid = ({
export const isAssignEmailAddressesInputValueValid = ({
learnerEmails,
remainingBalance,
contentPrice,
Expand All @@ -50,7 +50,7 @@ export const isEmailAddressesInputValueValid = ({
const learnerEmailsCount = learnerEmails.length;
const totalAssignmentCost = contentPrice * learnerEmailsCount;
const remainingBalanceAfterAssignment = remainingBalance - totalAssignmentCost;
const hasEnoughBalanceForAssigment = remainingBalanceAfterAssignment >= 0;
const hasEnoughBalanceForAssignment = remainingBalanceAfterAssignment >= 0;

const lowerCasedEmails = [];
const invalidEmails = [];
Expand All @@ -74,7 +74,7 @@ export const isEmailAddressesInputValueValid = ({
});

const isValidInput = invalidEmails.length === 0 && duplicateEmails.length === 0;
const canAllocate = learnerEmailsCount > 0 && hasEnoughBalanceForAssigment && isValidInput;
const canAllocate = learnerEmailsCount > 0 && hasEnoughBalanceForAssignment && isValidInput;

const ensureValidationErrorObjectExists = () => {
if (!validationError) {
Expand All @@ -91,7 +91,7 @@ export const isEmailAddressesInputValueValid = ({
validationError.reason = 'duplicate_email';
validationError.message = `${duplicateEmails[0]} has been entered more than once.`;
}
} else if (!hasEnoughBalanceForAssigment) {
} else if (!hasEnoughBalanceForAssignment) {
ensureValidationErrorObjectExists();
validationError.reason = 'insufficient_funds';
validationError.message = `The total assignment cost exceeds your available Learner Credit budget balance of ${formatPrice(remainingBalance)}. Please remove learners and try again.`;
Expand All @@ -104,6 +104,73 @@ export const isEmailAddressesInputValueValid = ({
validationError,
totalAssignmentCost,
remainingBalanceAfterAssignment,
hasEnoughBalanceForAssigment,
hasEnoughBalanceForAssignment,
};
};

/**
* Determine the validity of the learner emails user input. The input is valid if
* all emails are valid. Invalid and duplicate emails are returned.
*
* @param {Array<String>} learnerEmails List of learner emails.
*
* @returns Object containing various properties about the validity of the learner emails
* input, including a validation error when appropriate, and whether the member invitation
* should proceed.
*/
export const isInviteEmailAddressesInputValueValid = ({ learnerEmails }) => {
let validationError;

const learnerEmailsCount = learnerEmails.length;

const lowerCasedEmails = [];
const invalidEmails = [];
const duplicateEmails = [];

learnerEmails.forEach((email) => {
const lowerCasedEmail = email.toLowerCase();

// Validate the email address
if (!isEmail(email)) {
invalidEmails.push(email);
}

// Check for duplicates (case-insensitive)
if (lowerCasedEmails.includes(lowerCasedEmail)) {
duplicateEmails.push(email);
} else {
// Add to list of lower-cased emails already handled
lowerCasedEmails.push(lowerCasedEmail);
}
});

const isValidInput = invalidEmails.length === 0;
const canInvite = learnerEmailsCount > 0 && learnerEmailsCount < 1000 && isValidInput;
const duplicateEmailsCount = duplicateEmails.length;

const ensureValidationErrorObjectExists = () => {
if (!validationError) {
validationError = {};
}
};

if (!isValidInput) {
ensureValidationErrorObjectExists();
if (invalidEmails.length > 0) {
validationError.reason = 'invalid_email';
validationError.message = `${invalidEmails[0]} is not a valid email.`;
}
} else if (duplicateEmails.length > 0) {
ensureValidationErrorObjectExists();
validationError.reason = 'duplicate_email';
validationError.message = `${duplicateEmails[0]} has been entered more than once.`;
}

return {
canInvite,
lowerCasedEmails,
duplicateEmailsCount,
isValidInput,
validationError,
};
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { makePlural } from '../../../../utils';

const useSuccessfulAssignmentToastContextValue = () => {
const [isToastOpen, setIsToastOpen] = useState(false);
Expand All @@ -15,14 +16,12 @@ const useSuccessfulAssignmentToastContextValue = () => {
setIsToastOpen(false);
}, []);

const pluralizeLearner = (count) => (count === 1 ? 'learner' : 'learners');

const toastMessages = [];
if (learnersAllocatedCount > 0) {
toastMessages.push(`Course successfully assigned to ${learnersAllocatedCount} ${pluralizeLearner(learnersAllocatedCount)}.`);
toastMessages.push(`Course successfully assigned to ${makePlural(learnersAllocatedCount, 'learner')}.`);
}
if (learnersAlreadyAllocatedCount > 0) {
toastMessages.push(`${learnersAlreadyAllocatedCount} ${pluralizeLearner(learnersAlreadyAllocatedCount)} already had this course assigned.`);
toastMessages.push(`${makePlural(learnersAlreadyAllocatedCount, 'learner')} already had this course assigned.`);
}
const successfulAssignmentAllocationToastMessage = toastMessages.join(' ');

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useCallback, useMemo, useState } from 'react';
import { makePlural } from '../../../../utils';

const useSuccessfulInvitationToastContextValue = () => {
const [isToastOpen, setIsToastOpen] = useState(false);
const [learnersInvitedCount, setLearnersInvitedCount] = useState(0);

const handleDisplayToast = useCallback(({ totalLearnersInvited }) => {
setLearnersInvitedCount(totalLearnersInvited);
setIsToastOpen(true);
}, []);

const handleCloseToast = useCallback(() => {
setIsToastOpen(false);
}, []);

const toastMessages = [];
if (learnersInvitedCount > 0) {
toastMessages.push(`${makePlural(learnersInvitedCount, 'new member')} successfully added.`);
}
const successfulInvitationToastMessage = toastMessages.join(' ');

const successfulInvitationToast = useMemo(() => ({
isSuccessfulInvitationToastOpen: isToastOpen,
displayToastForInvitation: handleDisplayToast,
closeToastForInvitation: handleCloseToast,
totalLearnersInvited: learnersInvitedCount,
successfulInvitationToastMessage,
}), [
isToastOpen,
handleDisplayToast,
handleCloseToast,
learnersInvitedCount,
successfulInvitationToastMessage,
]);

return successfulInvitationToast;
};

export default useSuccessfulInvitationToastContextValue;
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {
Button, Card, Row, Col,
Button, Card, Col, Row,
} from '@edx/paragon';
import { Link } from 'react-router-dom';

Expand All @@ -22,7 +23,7 @@ const EnrollAndSpendIllustration = (props) => (
<img data-testid="enroll-and-spend-illustration" src={enrollAndSpend} alt="" {...props} />
);

const NoBnEBudgetActivity = () => {
const NoBnEBudgetActivity = ({ openInviteModal }) => {
const isLargeOrGreater = useIsLargeOrGreater();

return (
Expand Down Expand Up @@ -86,6 +87,7 @@ const NoBnEBudgetActivity = () => {
<Col>
<Button
as={Link}
onClick={openInviteModal}
>
Get started
</Button>
Expand All @@ -96,4 +98,8 @@ const NoBnEBudgetActivity = () => {
);
};

NoBnEBudgetActivity.propTypes = {
openInviteModal: PropTypes.func.isRequired,
};

export default NoBnEBudgetActivity;
Loading

0 comments on commit 0c1c27f

Please sign in to comment.