From 4bffff7eb7edbf4d88314e46db4b0fd1b9ea847d Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 24 Jun 2024 13:05:45 -0400 Subject: [PATCH] feat: assignment dashboard card messaging updates and alert on expiring assignments (#1105) --- src/components/app/data/constants.js | 2 + .../useEnterpriseCourseEnrollments.test.jsx | 1 + src/components/app/data/utils.js | 8 +- src/components/dashboard/DashboardPage.jsx | 5 - src/components/dashboard/data/constants.js | 2 + src/components/dashboard/data/utils.js | 32 +- .../CourseAssignmentAlert.jsx | 129 ++++--- .../course-enrollments/CourseEnrollments.jsx | 43 ++- .../course-cards/AssignedCourseCard.jsx | 8 +- .../course-cards/BaseCourseCard.jsx | 362 +++++++++++------- .../course-cards/InProgressCourseCard.jsx | 40 +- .../course-cards/SavedForLaterCourseCard.jsx | 24 +- .../tests/AssignedCourseCard.test.jsx | 15 +- .../tests/BaseCourseCard.test.jsx | 259 +++++++------ .../tests/InProgressCourseCard.test.jsx | 21 +- .../course-enrollments/data/hooks.js | 19 +- .../data/tests/hooks.test.jsx | 69 +++- .../course-enrollments/data/utils.js | 9 +- .../tests/CourseEnrollments.test.jsx | 101 +++-- .../enterprise-offers/data/constants.js | 1 + src/utils/common.js | 15 + 21 files changed, 784 insertions(+), 381 deletions(-) diff --git a/src/components/app/data/constants.js b/src/components/app/data/constants.js index e0e81b3a01..a2bb66cce5 100644 --- a/src/components/app/data/constants.js +++ b/src/components/app/data/constants.js @@ -33,3 +33,5 @@ export const COURSE_MODES_MAP = { HONOR: 'honor', PAID_EXECUTIVE_EDUCATION: 'paid-executive-education', }; + +export const ENROLL_BY_DATE_WARNING_THRESHOLD_DAYS = 10; diff --git a/src/components/app/data/hooks/useEnterpriseCourseEnrollments.test.jsx b/src/components/app/data/hooks/useEnterpriseCourseEnrollments.test.jsx index 36e06d81cb..6fe6a58649 100644 --- a/src/components/app/data/hooks/useEnterpriseCourseEnrollments.test.jsx +++ b/src/components/app/data/hooks/useEnterpriseCourseEnrollments.test.jsx @@ -167,6 +167,7 @@ describe('useEnterpriseCourseEnrollments', () => { enrollBy: mockContentAssignment.earliestPossibleExpiration.date, isCanceledAssignment: false, isExpiredAssignment: false, + isExpiringAssignment: false, assignmentConfiguration: mockContentAssignment.assignmentConfiguration, uuid: mockContentAssignment.uuid, learnerAcknowledged: mockContentAssignment.learnerAcknowledged, diff --git a/src/components/app/data/utils.js b/src/components/app/data/utils.js index 68307597f2..2dc72380df 100644 --- a/src/components/app/data/utils.js +++ b/src/components/app/data/utils.js @@ -3,10 +3,10 @@ import { logError } from '@edx/frontend-platform/logging'; import { ASSIGNMENT_TYPES, POLICY_TYPES } from '../../enterprise-user-subsidy/enterprise-offers/data/constants'; import { LICENSE_STATUS } from '../../enterprise-user-subsidy/data/constants'; -import { getBrandColorsFromCSSVariables } from '../../../utils/common'; +import { getBrandColorsFromCSSVariables, isTodayWithinDateThreshold } from '../../../utils/common'; import { COURSE_STATUSES, SUBSIDY_TYPE } from '../../../constants'; import { LATE_ENROLLMENTS_BUFFER_DAYS } from '../../../config/constants'; -import { COURSE_AVAILABILITY_MAP, COURSE_MODES_MAP } from './constants'; +import { COURSE_AVAILABILITY_MAP, COURSE_MODES_MAP, ENROLL_BY_DATE_WARNING_THRESHOLD_DAYS } from './constants'; import { features } from '../../../config'; /** @@ -379,6 +379,10 @@ export const transformLearnerContentAssignment = (learnerContentAssignment, ente enrollBy: assignmentEnrollByDeadline, isCanceledAssignment, isExpiredAssignment, + isExpiringAssignment: isTodayWithinDateThreshold({ + date: assignmentEnrollByDeadline, + days: ENROLL_BY_DATE_WARNING_THRESHOLD_DAYS, + }), assignmentConfiguration: learnerContentAssignment.assignmentConfiguration, uuid: learnerContentAssignment.uuid, learnerAcknowledged: learnerContentAssignment.learnerAcknowledged, diff --git a/src/components/dashboard/DashboardPage.jsx b/src/components/dashboard/DashboardPage.jsx index ac6fa4ddf0..94ddb4e4e3 100644 --- a/src/components/dashboard/DashboardPage.jsx +++ b/src/components/dashboard/DashboardPage.jsx @@ -81,11 +81,6 @@ const DashboardPage = () => { onClose={handleSubscriptionLicenseActivationAlertClose} className="mt-3" dismissible - closeLabel={intl.formatMessage({ - id: 'enterprise.dashboard.course.assignment.alert.dismiss.button.label', - defaultMessage: 'Dismiss', - description: 'Dismiss button label for the course assignment alert', - })} > (`hasSeenSubscriptionLicenseExpiredModal-${uuid}`); export const EXPIRING_SUBSCRIPTION_MODAL_LOCALSTORAGE_KEY = ({ uuid, threshold }) => (`${SEEN_SUBSCRIPTION_EXPIRATION_MODAL_COOKIE_PREFIX}${threshold}-${uuid}`); + +export const ASSIGNMENTS_EXPIRING_WARNING_LOCALSTORAGE_KEY = 'enterprise.learner-portal.assignment-expiration-alert.dismissed.assignment.uuids'; diff --git a/src/components/dashboard/data/utils.js b/src/components/dashboard/data/utils.js index 2a05928bd3..e849de9240 100644 --- a/src/components/dashboard/data/utils.js +++ b/src/components/dashboard/data/utils.js @@ -1,4 +1,4 @@ -import { BUDGET_STATUSES } from './constants'; +import { ASSIGNMENTS_EXPIRING_WARNING_LOCALSTORAGE_KEY, BUDGET_STATUSES } from './constants'; /** * Determines whether there are any unacknowledged assignments. @@ -10,6 +10,36 @@ export function getHasUnacknowledgedAssignments(assignments) { return assignments.some((assignment) => !assignment.learnerAcknowledged); } +export function getExpiringAssignmentsAcknowledgementState(assignments) { + const alreadyAcknowledgedExpiringAssignments = JSON.parse( + global.localStorage.getItem(ASSIGNMENTS_EXPIRING_WARNING_LOCALSTORAGE_KEY), + ) || []; + + const expiringAssignments = []; + const unacknowledgedExpiringAssignments = []; + const acknowledgedExpiringAssignments = []; + + assignments.forEach((assignment) => { + if (!assignment.isExpiringAssignment) { + return; + } + expiringAssignments.push(assignment); + if (alreadyAcknowledgedExpiringAssignments.includes(assignment.uuid)) { + acknowledgedExpiringAssignments.push(assignment); + } else { + unacknowledgedExpiringAssignments.push(assignment); + } + }); + + return { + expiringAssignments, + unacknowledgedExpiringAssignments, + hasUnacknowledgedExpiringAssignments: unacknowledgedExpiringAssignments.length > 0, + acknowledgedExpiringAssignments, + hasAcknowledgedExpiringAssignments: acknowledgedExpiringAssignments.length > 0, + }; +} + // Utility function to check the budget status export const getStatusMetadata = ({ isPlanApproachingExpiry, diff --git a/src/components/dashboard/main-content/course-enrollments/CourseAssignmentAlert.jsx b/src/components/dashboard/main-content/course-enrollments/CourseAssignmentAlert.jsx index c2ff4d979b..d42d763bce 100644 --- a/src/components/dashboard/main-content/course-enrollments/CourseAssignmentAlert.jsx +++ b/src/components/dashboard/main-content/course-enrollments/CourseAssignmentAlert.jsx @@ -1,12 +1,75 @@ import PropTypes from 'prop-types'; import { Alert, Button, MailtoLink } from '@openedx/paragon'; -import { Info } from '@openedx/paragon/icons'; +import { Info, Warning } from '@openedx/paragon/icons'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { getContactEmail } from '../../../../utils/common'; import { ASSIGNMENT_TYPES } from '../../../enterprise-user-subsidy/enterprise-offers/data/constants'; import { useEnterpriseCustomer } from '../../../app/data'; +const alertMessagingByVariant = { + [ASSIGNMENT_TYPES.CANCELED]: { + heading: ( + + ), + text: ( + + ), + variant: 'danger', + hasContactAdministrator: true, + isDismissable: true, + icon: Info, + }, + [ASSIGNMENT_TYPES.EXPIRED]: { + heading: ( + + ), + text: ( + + ), + variant: 'danger', + hasContactAdministrator: true, + isDismissable: true, + icon: Info, + }, + [ASSIGNMENT_TYPES.EXPIRING]: { + heading: ( + + ), + text: ( + + ), + variant: 'warning', + hasContactAdministrator: false, + isDismissable: true, + icon: Warning, + }, +}; + const CourseAssignmentAlert = ({ showAlert, onClose, @@ -14,52 +77,30 @@ const CourseAssignmentAlert = ({ isAcknowledgingAssignments, }) => { const intl = useIntl(); - const heading = variant === ASSIGNMENT_TYPES.CANCELED ? ( - - ) : ( - - ); - - const text = variant === ASSIGNMENT_TYPES.CANCELED ? ( - - ) : ( - - ); - const { data: enterpriseCustomer } = useEnterpriseCustomer(); const adminEmail = getContactEmail(enterpriseCustomer); + const alertMessaging = alertMessagingByVariant[variant]; + if (!alertMessaging) { + return null; + } + + const alertActions = alertMessaging.hasContactAdministrator ? [ + , + ] : []; return ( - - , - ]} + icon={alertMessaging.icon} + dismissible={alertMessaging.isDismissable} + actions={alertActions} onClose={onClose} closeLabel={isAcknowledgingAssignments ? intl.formatMessage({ @@ -73,15 +114,15 @@ const CourseAssignmentAlert = ({ description: 'Dismiss button label for the course assignment alert', })} > - {heading} -

{text}

+ {alertMessaging.heading} +

{alertMessaging.text}

); }; CourseAssignmentAlert.propTypes = { onClose: PropTypes.func, - variant: PropTypes.oneOf([ASSIGNMENT_TYPES.CANCELED, ASSIGNMENT_TYPES.EXPIRED]), + variant: PropTypes.oneOf([ASSIGNMENT_TYPES.CANCELED, ASSIGNMENT_TYPES.EXPIRED, ASSIGNMENT_TYPES.EXPIRING]), showAlert: PropTypes.bool, isAcknowledgingAssignments: PropTypes.bool, }; diff --git a/src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx b/src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx index 45ad0a9934..d4f4cea0a5 100644 --- a/src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx +++ b/src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx @@ -75,6 +75,8 @@ const CourseEnrollments = ({ children }) => { assignments, showCanceledAssignmentsAlert, showExpiredAssignmentsAlert, + showExpiringAssignmentsAlert, + handleAcknowledgeExpiringAssignments, handleAcknowledgeAssignments, isAcknowledgingAssignments, } = useContentAssignments(allEnrollmentsByStatus.assigned); @@ -110,6 +112,24 @@ const CourseEnrollments = ({ children }) => { enterpriseCustomer={enterpriseCustomer} /> )} + {shouldShowMarkSavedForLaterCourseSuccess && ( + setShouldShowMarkSavedForLaterCourseSuccess(false)}> + + + )} + {shouldShowMoveToInProgressCourseSuccess && ( + setShouldShowMoveToInProgressCourseSuccess(false)}> + + + )} {features.FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT && ( <> { })} isAcknowledgingAssignments={isAcknowledgingAssignments} /> - {shouldShowMarkSavedForLaterCourseSuccess && ( - setShouldShowMarkSavedForLaterCourseSuccess(false)}> - - - )} - {shouldShowMoveToInProgressCourseSuccess && ( - setShouldShowMoveToInProgressCourseSuccess(false)}> - - - )} + { // background) should be using the brand variant. variant="inverse-brand" > - Enroll + ); @@ -39,6 +44,7 @@ const AssignedCourseCard = (props) => { type={COURSE_STATUSES.assigned} hasViewCertificateLink={false} canUnenroll={false} + externalCourseLink={false} {...props} /> ); diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/BaseCourseCard.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/BaseCourseCard.jsx index dd7b49d4b7..59b5b5922b 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/BaseCourseCard.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/BaseCourseCard.jsx @@ -1,39 +1,104 @@ import { useContext, useState } from 'react'; import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; import { - Badge, Col, Dropdown, Icon, IconButton, OverlayTrigger, Row, Skeleton, Tooltip, + Badge, Col, Dropdown, Icon, IconButton, IconButtonWithTooltip, Row, Skeleton, Hyperlink, Stack, } from '@openedx/paragon'; +import { + Info, + InfoOutline, + MoreVert, + Warning, +} from '@openedx/paragon/icons'; import { v4 as uuidv4 } from 'uuid'; import classNames from 'classnames'; +import { FormattedMessage, defineMessages, useIntl } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; -import { Info, InfoOutline, MoreVert } from '@openedx/paragon/icons'; import dayjs from '../../../../../utils/dayjs'; import { EmailSettingsModal } from './email-settings'; import { UnenrollModal } from './unenroll'; import { COURSE_PACING, COURSE_STATUSES, EXECUTIVE_EDUCATION_COURSE_MODES } from '../../../../../constants'; -import { useEnterpriseCustomer } from '../../../../app/data'; +import { ENROLL_BY_DATE_WARNING_THRESHOLD_DAYS, useEnterpriseCustomer } from '../../../../app/data'; +import { isTodayWithinDateThreshold } from '../../../../../utils/common'; + +const messages = defineMessages({ + statusBadgeLabelInProgress: { + id: 'enterprise.learner_portal.dashboard.enrollments.course.status_badge_label.in_progress', + defaultMessage: 'In Progress', + description: 'The label for the status badge for courses that are in-progress', + }, + statusBadgeLabelUpcoming: { + id: 'enterprise.learner_portal.dashboard.enrollments.course.status_badge_label.upcoming', + defaultMessage: 'Upcoming', + description: 'The label for the status badge for courses that are upcoming', + }, + statusBadgeLabelRequested: { + id: 'enterprise.learner_portal.dashboard.enrollments.course.status_badge_label.requested', + defaultMessage: 'Requested', + description: 'The label for the status badge for courses that are requested', + }, + statusBadgeLabelAssigned: { + id: 'enterprise.learner_portal.dashboard.enrollments.course.status_badge_label.assigned', + defaultMessage: 'Assigned', + description: 'The label for the status badge for courses that are assigned', + }, + emailSettings: { + id: 'enterprise.learner_portal.dashboard.enrollments.course.email_settings', + defaultMessage: 'Email settings for {courseTitle}', + description: 'The label for the email settings option in the course card dropdown', + }, + unenroll: { + id: 'enterprise.learner_portal.dashboard.enrollments.course.unenroll', + defaultMessage: 'Unenroll from {courseTitle}', + description: 'The label for the unenroll option in the course card dropdown', + }, + requestedCourseHelpText: { + id: 'enterprise.learner_portal.dashboard.enrollments.course.requested.help_text', + defaultMessage: 'Please allow 5-10 business days for review. If approved, you will receive an email to get started.', + description: 'Help text for requested courses', + }, + enrollByDateWarning: { + id: 'enterprise.learner_portal.dashboard.enrollments.course.enroll_by_date_warning', + defaultMessage: 'Enroll by {enrollByDate}', + description: 'Warning message for enrollment deadline approaching', + }, + enrollByDateWarningTooltipAlt: { + id: 'enterprise.learner_portal.dashboard.enrollments.course.enroll_by_date_warning.tooltip_alt', + defaultMessage: 'Learn more about enrollment deadline for {courseTitle}', + description: 'Tooltip alt text for enrollment deadline approaching', + }, + enrollByDateWarningTooltipContent: { + id: 'enterprise.learner_portal.dashboard.enrollments.course.enroll_by_date_warning.tooltip_content', + defaultMessage: 'Enrollment deadline approaching', + description: 'Tooltip content for enrollment deadline approaching', + }, +}); const BADGE_PROPS_BY_COURSE_STATUS = { [COURSE_STATUSES.inProgress]: { variant: 'success', - children: 'In Progress', + children: , }, [COURSE_STATUSES.upcoming]: { variant: 'primary', - children: 'Upcoming', + children: , }, [COURSE_STATUSES.requested]: { variant: 'secondary', - children: 'Requested', + children: , }, [COURSE_STATUSES.assigned]: { variant: 'info', - children: 'Assigned', + children: , }, }; +export const getScreenReaderText = (str) => ( + {str} +); + const BaseCourseCard = ({ hasEmailsEnabled: defaultHasEmailsEnabled, title, @@ -48,10 +113,10 @@ const BaseCourseCard = ({ type, microMastersTitle, orgName, - courseRunStatus, children, buttons, linkToCourse, + externalCourseLink, linkToCertificate, miscText, isCourseAssigned, @@ -60,6 +125,7 @@ const BaseCourseCard = ({ isLoading, hasViewCertificateLink, }) => { + const intl = useIntl(); const { config, authenticatedUser } = useContext(AppContext); const { data: enterpriseCustomer } = useEnterpriseCustomer(); const [hasEmailsEnabled, setHasEmailsEnabled] = useState(defaultHasEmailsEnabled); @@ -72,6 +138,8 @@ const BaseCourseCard = ({ options: {}, }); + const CourseTitleComponent = externalCourseLink ? Hyperlink : Link; + const handleUnenrollButtonClick = () => { setUnenrollModal((prevState) => ({ ...prevState, @@ -110,8 +178,13 @@ const BaseCourseCard = ({ onClick: handleEmailSettingsButtonClick, children: (
- Email settings - for {title} +
), }); @@ -123,8 +196,13 @@ const BaseCourseCard = ({ onClick: handleUnenrollButtonClick, children: (
- Unenroll - from {title} +
), }); @@ -143,7 +221,6 @@ const BaseCourseCard = ({ message += isCourseEnded ? 'was ' : 'is '; message += `${pacing}-paced. `; } - return message; }; @@ -199,106 +276,105 @@ const BaseCourseCard = ({ const renderSettingsDropdown = (menuItems) => { const isExecutiveEducation2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(mode); - const execEdClass = isExecutiveEducation2UCourse ? 'text-light-100' : ''; - if (menuItems && menuItems.length > 0) { - return ( -
- - - - {menuItems.map(menuItem => ( - - {menuItem.children} - - ))} - - -
- ); + const execEdClass = isExecutiveEducation2UCourse ? 'text-light-100' : undefined; + + if (!menuItems?.length) { + return null; } - return null; + + return ( +
+ + + + {menuItems.map(menuItem => ( + + {menuItem.children} + + ))} + + +
+ ); }; const renderEmailSettingsModal = () => { - if (hasEmailsEnabled !== null) { - return ( - - ); + if (!hasEmailsEnabled) { + return null; } - return null; + return ( + + ); }; const renderAdditionalInfoOutline = () => { - if (type === COURSE_STATUSES.requested) { - return ( - - Please allow 5-10 business days for review. - If approved, you will receive an email to get started. - - ); + if (type !== COURSE_STATUSES.requested) { + return null; } - return null; + return ( + + + + ); }; const renderMicroMastersTitle = () => { - if (microMastersTitle) { - return ( -

- {microMastersTitle} -

- ); + if (!microMastersTitle) { + return null; } - return null; + return ( +

+ {microMastersTitle} +

+ ); }; const renderOrganizationName = () => { + if (!orgName) { + return null; + } const isExecutiveEducation2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(mode); const courseTypeLabel = isExecutiveEducation2UCourse ? 'Executive Education' : 'Course'; const tooltipText = isExecutiveEducation2UCourse ? 'Executive Education courses are instructor-led, cohort-based, and follow a set schedule.' : 'Courses are on-demand, self-paced, and include asynchronous online discussion.'; - if (orgName) { - return ( -

+ return ( + +

{orgName} • {courseTypeLabel} - - {tooltipText} - - )} - > - -

- ); - } - return null; + + + ); }; const renderStartDate = () => { const formattedStartDate = startDate ? dayjs(startDate).format('MMMM Do, YYYY') : null; const isCourseStarted = dayjs(startDate) <= dayjs(); - if (formattedStartDate && !isCourseStarted) { return Starts {formattedStartDate}; } @@ -308,7 +384,6 @@ const BaseCourseCard = ({ const renderEndDate = () => { const formattedEndDate = endDate ? dayjs(endDate).format('MMMM Do, YYYY') : null; const isCourseStarted = dayjs(startDate).isBefore(dayjs()); - if (formattedEndDate && isCourseStarted && type !== COURSE_STATUSES.completed) { return Ends {formattedEndDate}; } @@ -316,12 +391,34 @@ const BaseCourseCard = ({ }; const renderEnrollByDate = () => { - const formattedEnrollByDate = enrollBy ? dayjs(enrollBy).format('MMMM Do, YYYY') : null; - - if (formattedEnrollByDate && courseRunStatus === COURSE_STATUSES.assigned) { - return Enroll by {formattedEnrollByDate}; + if (!enrollBy || type !== COURSE_STATUSES.assigned) { + return null; } - return null; + const isEnrollByExpiringSoon = isTodayWithinDateThreshold({ + days: ENROLL_BY_DATE_WARNING_THRESHOLD_DAYS, + date: enrollBy, + }); + const enrollByDate = dayjs(enrollBy); + const isEnrollByDateMidnight = enrollByDate.hour() === 0 && enrollByDate.minute() === 0; + const baseFormatStr = 'MMMM Do, YYYY'; + const enrollByDateFormat = isEnrollByDateMidnight ? baseFormatStr : `h:mma ${baseFormatStr}`; + const formattedEnrollByDate = enrollByDate.format(enrollByDateFormat); + return ( + + + {isEnrollByExpiringSoon && ( + + )} + + ); }; const renderCourseInfoOutline = () => { @@ -345,7 +442,7 @@ const BaseCourseCard = ({ } return ( -

+

{dateFields.map((dateField, index) => { const isLastDateField = index === dateFields.length - 1; return ( @@ -355,47 +452,47 @@ const BaseCourseCard = ({ ); })} -

+
); }; const renderChildren = () => { if (children) { return ( -
-
+ + {children} -
-
+ + ); } return null; }; const renderButtons = () => { - if (buttons) { - return ( -
-
- {buttons} -
-
- ); + if (!buttons) { + return null; } - return null; + return ( + + + {buttons} + + + ); }; const renderViewCertificateText = () => { - if (linkToCertificate) { - return ( - - View your certificate on - {' '} - your profile → - - ); + if (!linkToCertificate) { + return null; } - return null; + return ( + + View your certificate on + {' '} + your profile → + + ); }; const renderMiscText = () => { @@ -414,13 +511,11 @@ const BaseCourseCard = ({ }; const renderBadge = () => { - const badgeProps = (isCourseAssigned) - ? BADGE_PROPS_BY_COURSE_STATUS.assigned - : BADGE_PROPS_BY_COURSE_STATUS[type]; - if (badgeProps) { - return ; + const badgeProps = (isCourseAssigned) ? BADGE_PROPS_BY_COURSE_STATUS.assigned : BADGE_PROPS_BY_COURSE_STATUS[type]; + if (!badgeProps) { + return null; } - return null; + return ; }; const renderAssignmentAlert = () => { @@ -455,12 +550,18 @@ const BaseCourseCard = ({
{renderMicroMastersTitle()} -
-

- {title} + +

+ + {title} +

{renderBadge()} -

+ {renderOrganizationName()}
{renderSettingsDropdown(dropdownMenuItems)} @@ -475,8 +576,13 @@ const BaseCourseCard = ({ {hasViewCertificateLink && renderViewCertificateText()} - { (isCanceledAssignment || isExpiredAssignment) && ( - + {(isCanceledAssignment || isExpiredAssignment) && ( + {renderAssignmentAlert()} )} @@ -492,6 +598,7 @@ BaseCourseCard.propTypes = { type: PropTypes.oneOf(Object.values(COURSE_STATUSES)).isRequired, title: PropTypes.string.isRequired, linkToCourse: PropTypes.string.isRequired, + externalCourseLink: PropTypes.bool, courseRunId: PropTypes.string.isRequired, mode: PropTypes.string.isRequired, hasViewCertificateLink: PropTypes.bool, @@ -514,7 +621,6 @@ BaseCourseCard.propTypes = { isLoading: PropTypes.bool, miscText: PropTypes.node, enrollBy: PropTypes.string, - courseRunStatus: PropTypes.string, isCourseAssigned: PropTypes.bool, isCanceledAssignment: PropTypes.bool, isExpiredAssignment: PropTypes.bool, @@ -536,10 +642,10 @@ BaseCourseCard.defaultProps = { isLoading: false, miscText: null, enrollBy: null, - courseRunStatus: null, isCourseAssigned: false, isCanceledAssignment: false, isExpiredAssignment: false, + externalCourseLink: true, }; export default BaseCourseCard; diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx index 3566df10cf..7e2162b673 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx @@ -3,9 +3,10 @@ import PropTypes from 'prop-types'; import { AppContext } from '@edx/frontend-platform/react'; import { useNavigate } from 'react-router-dom'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { FormattedMessage, defineMessages } from '@edx/frontend-platform/i18n'; import dayjs from '../../../../../utils/dayjs'; -import BaseCourseCard from './BaseCourseCard'; +import BaseCourseCard, { getScreenReaderText } from './BaseCourseCard'; import { MarkCompleteModal } from './mark-complete-modal'; import ContinueLearningButton from './ContinueLearningButton'; @@ -14,7 +15,15 @@ import Notification from './Notification'; import { UpgradeableCourseEnrollmentContext } from '../UpgradeableCourseEnrollmentContextProvider'; import UpgradeCourseButton from './UpgradeCourseButton'; import { useEnterpriseCustomer } from '../../../../app/data'; -import { useUpdateCourseEnrollmentStatus } from '../data'; +import { COURSE_STATUSES, useUpdateCourseEnrollmentStatus } from '../data'; + +const messages = defineMessages({ + saveCourseForLater: { + id: 'enterprise.learner_portal.dashboard.enrollments.course.save_for_later', + defaultMessage: 'Save course for later for {courseTitle}', + description: 'Text for the save course for later button in the course card dropdown menu', + }, +}); export const InProgressCourseCard = ({ linkToCourse, @@ -79,16 +88,19 @@ export const InProgressCourseCard = ({ sendEnterpriseTrackEvent( enterpriseCustomer.uuid, 'edx.ui.enterprise.learner_portal.dashboard.course.mark_complete.modal.opened', - { - course_run_id: courseRunId, - }, + { course_run_id: courseRunId }, ); }, children: ( - <> - Save course for later - for {title} - +
+ +
), }]; } @@ -100,9 +112,7 @@ export const InProgressCourseCard = ({ sendEnterpriseTrackEvent( enterpriseCustomer.uuid, 'edx.ui.enterprise.learner_portal.dashboard.course.mark_complete.modal.closed', - { - course_run_id: courseRunId, - }, + { course_run_id: courseRunId }, ); }; @@ -110,9 +120,7 @@ export const InProgressCourseCard = ({ sendEnterpriseTrackEvent( enterpriseCustomer.uuid, 'edx.ui.enterprise.learner_portal.dashboard.course.mark_complete.saved', - { - course_run_id: courseRunId, - }, + { course_run_id: courseRunId }, ); setIsMarkCompleteModalOpen(false); resetModalState(); @@ -155,7 +163,7 @@ export const InProgressCourseCard = ({ return ( for {courseTitle}', + description: 'Text for the unsave course for later button in the course card dropdown menu', + }, +}); + const SavedForLaterCourseCard = (props) => { const { title, @@ -81,15 +90,18 @@ const SavedForLaterCourseCard = (props) => { sendEnterpriseTrackEvent( enterpriseCustomer.uuid, 'edx.ui.enterprise.learner_portal.dashboard.course.move_to_in_progress.modal.opened', - { - course_run_id: courseRunId, - }, + { course_run_id: courseRunId }, ); }, children: (
- Move to In Progress - for {title} +
), }, diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/tests/AssignedCourseCard.test.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/tests/AssignedCourseCard.test.jsx index 991b06196c..488b957326 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/tests/AssignedCourseCard.test.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/tests/AssignedCourseCard.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import { screen } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; @@ -10,20 +11,26 @@ jest.mock('../../../../../app/data', () => ({ useEnterpriseCustomer: jest.fn().mockReturnValue({ data: { uuid: 123 } }), })); -const basicProps = { +const baseProps = { title: 'edX Demonstration Course', linkToCourse: 'https://edx.org', courseRunStatus: 'upcoming', courseRunId: 'my+course+key', courseKey: 'my+course+key', notifications: [], - mode: 'executive-education', + mode: 'verified-audit', }; +const AssignedCourseCardWrapper = (props) => ( + + + +); + describe('', () => { it('should render enroll button and other related content', () => { - renderWithRouter(); + renderWithRouter(); expect(screen.getByText('Assigned')).toBeInTheDocument(); - expect(screen.getByText('Enroll')).toBeInTheDocument(); + expect(screen.getByText('Go to enrollment')).toBeInTheDocument(); }); }); diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/tests/BaseCourseCard.test.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/tests/BaseCourseCard.test.jsx index 485c371f28..cf3872d4f5 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/tests/BaseCourseCard.test.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/tests/BaseCourseCard.test.jsx @@ -1,8 +1,10 @@ import React from 'react'; import { AppContext } from '@edx/frontend-platform/react'; import { renderWithRouter } from '@edx/frontend-enterprise-utils'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import MockDate from 'mockdate'; import '@testing-library/jest-dom/extend-expect'; import { QueryClientProvider } from '@tanstack/react-query'; @@ -13,6 +15,7 @@ import { useEnterpriseCustomer } from '../../../../../app/data'; import { queryClient } from '../../../../../../utils/tests'; import { authenticatedUserFactory, enterpriseCustomerFactory } from '../../../../../app/data/services/data/__factories__'; +import { COURSE_STATUSES } from '../../data'; jest.mock('@edx/frontend-enterprise-utils', () => ({ ...jest.requireActual('@edx/frontend-enterprise-utils'), @@ -24,82 +27,82 @@ jest.mock('../../../../../app/data', () => ({ useEnterpriseCustomer: jest.fn(), })); +const mockAddToast = jest.fn(); + const mockEnterpriseCustomer = enterpriseCustomerFactory(); const mockAuthenticatedUser = authenticatedUserFactory(); +const BaseCourseCardWrapper = (props) => ( + + + + + + + + + +); + describe('', () => { beforeEach(() => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); }); + afterEach(() => { + MockDate.reset(); + }); + describe('email settings modal', () => { beforeEach(async () => { renderWithRouter(( - - - + )); // open email settings modal userEvent.click(screen.getByLabelText('course settings for edX Demonstration Course')); - await waitFor(() => { - expect(screen.getByRole('menuitem')).toBeInTheDocument(); - }); + expect(await screen.findByRole('menuitem')).toBeInTheDocument(); userEvent.click(screen.getByRole('menuitem')); - await waitFor(() => { - expect(screen.getByRole('dialog').parentElement.getAttribute('class')).toContain('show'); - }); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); }); - it('test modal close/cancel', async () => { - userEvent.click(screen.getByTestId('modal-footer-btn')); + it('handles email settings modal close/cancel', async () => { + userEvent.click(screen.getByTestId('modal-footer-btn', { name: 'Close' })); await waitFor(() => { - expect(screen.getByRole('dialog').parentElement.getAttribute('class')).not.toContain('show'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); }); }); describe('unenroll modal', () => { - const mockAddToast = jest.fn(); - beforeEach(async () => { jest.clearAllMocks(); - renderWithRouter(( - - - - - - - - )); + renderWithRouter( + , + ); // open unenroll modal userEvent.click(screen.getByLabelText('course settings for edX Demonstration Course')); - await waitFor(() => { - expect(screen.getByRole('menuitem')).toBeInTheDocument(); - }); + expect(await screen.findByRole('menuitem')).toBeInTheDocument(); userEvent.click(screen.getByRole('menuitem')); - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); expect(screen.getByText('Unenroll from course?')).toBeInTheDocument(); }); - it('test modal close/cancel', async () => { + it('handles unenroll modal close/cancel', async () => { userEvent.click(screen.getByRole('button', { name: 'Keep learning' })); await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); @@ -109,17 +112,15 @@ describe('', () => { it('should render Skeleton if isLoading = true', () => { renderWithRouter( - - - , + , ); expect(screen.getByText('Loading...')).toBeInTheDocument(); }); @@ -129,26 +130,24 @@ describe('', () => { const yesterday = dayjs().subtract(1, 'day').toISOString(); const tomorrow = dayjs().add(1, 'day').toISOString(); - [today, yesterday, tomorrow].forEach(startDate => { + [today, yesterday, tomorrow].forEach((startDate) => { const formattedStartDate = dayjs(startDate).format('MMMM Do, YYYY'); const isCourseStarted = dayjs(startDate) <= dayjs(); renderWithRouter( - - - , + , ); if (!isCourseStarted) { expect(screen.getByText(`Starts ${formattedStartDate}`)).toBeInTheDocument(); @@ -158,29 +157,27 @@ describe('', () => { }); }); - it('renders endDate based on the course state', () => { + it.each([ + { type: COURSE_STATUSES.inProgress }, + { type: COURSE_STATUSES.completed }, + ])('renders endDate based on the course state', ({ type }) => { const startDate = dayjs().subtract(7, 'days').toISOString(); const endDate = dayjs().add(7, 'days').toISOString(); const formattedEndDate = dayjs(endDate).format('MMMM Do, YYYY'); - const type = 'in_progress'; - renderWithRouter( - - - , + , ); - const shouldRenderEndDate = dayjs(startDate) <= dayjs() && type !== 'completed'; if (shouldRenderEndDate) { expect(screen.getByText(`Ends ${formattedEndDate}`)).toBeInTheDocument(); @@ -189,31 +186,73 @@ describe('', () => { } }); - it('renders Enroll By Date if the user is not enrolled', () => { - const enrollBy = dayjs().add(14, 'days').toISOString(); - const formattedEnrollByDate = dayjs(enrollBy).format('MMMM Do, YYYY'); - const courseRunStatus = 'assigned'; - + it.each([ + { + enrollByDate: '2024-06-15T14:00:00Z', + courseRunStatus: COURSE_STATUSES.assigned, + expectedEnrollByDateFormat: 'h:mma MMMM Do, YYYY', + currentTimestamp: '2024-06-10T14:00:00Z', + hasExpiringWarningTooltip: true, + }, + { + enrollByDate: '2024-06-10T14:00:00Z', + courseRunStatus: COURSE_STATUSES.assigned, + expectedEnrollByDateFormat: 'h:mma MMMM Do, YYYY', + currentTimestamp: null, + hasExpiringWarningTooltip: false, + }, + { + enrollByDate: '2024-06-10T00:00:00Z', + courseRunStatus: COURSE_STATUSES.assigned, + expectedEnrollByDateFormat: 'MMMM Do, YYYY', // parsed time is midnight; should NOT show time + currentTimestamp: null, + hasExpiringWarningTooltip: false, + }, + { + enrollByDate: dayjs().add(5, 'days').toISOString(), + courseRunStatus: COURSE_STATUSES.inProgress, + expectedEnrollByDateFormat: null, + currentTimestamp: null, + hasExpiringWarningTooltip: false, + }, + ])('renders "Enroll By" date for assigned course cards (%s)', async ({ + enrollByDate, + courseRunStatus, + expectedEnrollByDateFormat, + currentTimestamp, + hasExpiringWarningTooltip, + }) => { + if (currentTimestamp) { + MockDate.set(currentTimestamp); + } renderWithRouter( - - - , + , ); - const isAssigned = courseRunStatus === 'assigned'; - if (isAssigned) { - expect(screen.getByText(`Enroll by ${formattedEnrollByDate}`)).toBeInTheDocument(); + if (expectedEnrollByDateFormat) { + const expectedFormattedEnrollByDate = dayjs(enrollByDate).format(expectedEnrollByDateFormat); + expect(screen.getByText(`Enroll by ${expectedFormattedEnrollByDate}`)).toBeInTheDocument(); + } else { + expect(screen.queryByText('Enroll by')).not.toBeInTheDocument(); + } + + const expectedExpiringWarningAlt = 'Learn more about enrollment deadline for edX Demonstration Course'; + if (hasExpiringWarningTooltip) { + const expiringWarningIconButton = screen.getByLabelText(expectedExpiringWarningAlt); + userEvent.click(expiringWarningIconButton); + expect(await screen.findByText('Enrollment deadline approaching')).toBeInTheDocument(); } else { - expect(screen.queryByText(`Enroll by ${formattedEnrollByDate}`)).not.toBeInTheDocument(); + const expiringWarningIconButton = screen.queryByLabelText(expectedExpiringWarningAlt); + expect(expiringWarningIconButton).not.toBeInTheDocument(); } }); }); diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/tests/InProgressCourseCard.test.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/tests/InProgressCourseCard.test.jsx index ee58b09c46..7d4ee5295a 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/tests/InProgressCourseCard.test.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/tests/InProgressCourseCard.test.jsx @@ -2,8 +2,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { renderWithRouter } from '@edx/frontend-enterprise-utils'; -import { QueryClientProvider } from '@tanstack/react-query'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; +import { QueryClientProvider } from '@tanstack/react-query'; import { UpgradeableCourseEnrollmentContext } from '../../UpgradeableCourseEnrollmentContextProvider'; import { InProgressCourseCard } from '../InProgressCourseCard'; @@ -16,7 +17,7 @@ jest.mock('@edx/frontend-enterprise-utils', () => ({ sendEnterpriseTrackEvent: jest.fn(), })); -const basicProps = { +const baseProps = { courseRunStatus: 'in_progress', title: 'edX Demonstration Course', linkToCourse: 'https://edx.org', @@ -47,11 +48,13 @@ const InProgressCourseCardWrapper = ({ ...rest }) => ( - - - - - + + + + + + + ); @@ -69,13 +72,13 @@ describe('', () => { }); it('should not render upgrade course button if there is no couponUpgradeUrl', () => { - renderWithRouter(); + renderWithRouter(); expect(screen.queryByTestId('upgrade-course-button')).not.toBeInTheDocument(); }); it('should render upgrade course button if there is a couponUpgradeUrl', () => { renderWithRouter( { + const { expiringAssignments } = getExpiringAssignmentsAcknowledgementState(assignments); + global.localStorage.setItem( + ASSIGNMENTS_EXPIRING_WARNING_LOCALSTORAGE_KEY, + JSON.stringify(expiringAssignments.map(assignment => assignment.uuid)), + ); + setShowExpiringAssignmentsAlert(false); + }, [assignments]); + const { mutateAsync, isLoading: isLoadingMutation, @@ -326,6 +337,10 @@ export function useContentAssignments() { const sortedAssignmentsForDisplay = sortAssignmentsByAssignmentStatus(assignmentsForDisplay); setAssignments(sortedAssignmentsForDisplay); + // Determine whether there are expiring assignments. If so, display alert. + const { hasUnacknowledgedExpiringAssignments } = getExpiringAssignmentsAcknowledgementState(assignmentsForDisplay); + setShowExpiringAssignmentsAlert(hasUnacknowledgedExpiringAssignments); + // Determine whether there are unacknowledged canceled assignments. If so, display alert. const hasUnacknowledgedCanceledAssignments = getHasUnacknowledgedAssignments(canceledAssignments); setShowCanceledAssignmentsAlert(hasUnacknowledgedCanceledAssignments); @@ -339,6 +354,8 @@ export function useContentAssignments() { assignments, showCanceledAssignmentsAlert, showExpiredAssignmentsAlert, + showExpiringAssignmentsAlert, + handleAcknowledgeExpiringAssignments, handleAcknowledgeAssignments, isAcknowledgingAssignments, }; diff --git a/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx b/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx index 97fdade034..220cf63a7f 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx +++ b/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx @@ -20,6 +20,7 @@ import { createRawCourseEnrollment } from '../../tests/enrollment-testutils'; import { createEnrollWithLicenseUrl, createEnrollWithCouponCodeUrl } from '../../../../../course/data/utils'; import { ASSIGNMENT_TYPES } from '../../../../../enterprise-user-subsidy/enterprise-offers/data/constants'; import { + ENROLL_BY_DATE_WARNING_THRESHOLD_DAYS, emptyRedeemableLearnerCreditPolicies, transformCourseEnrollment, transformLearnerContentAssignment, @@ -28,6 +29,7 @@ import { useRedeemablePolicies, } from '../../../../../app/data'; import { authenticatedUserFactory, enterpriseCustomerFactory } from '../../../../../app/data/services/data/__factories__'; +import { ASSIGNMENTS_EXPIRING_WARNING_LOCALSTORAGE_KEY } from '../../../../data/constants'; jest.mock('../service'); jest.mock('@edx/frontend-platform/logging', () => ({ @@ -342,7 +344,7 @@ describe('useCourseEnrollments', () => { describe('useContentAssignments', () => { const mockRedeemableLearnerCreditPolicies = emptyRedeemableLearnerCreditPolicies; - const mockSubsidyExpirationDateStr = dayjs().add(1, 'd').toISOString(); + const mockSubsidyExpirationDateStr = dayjs().add(ENROLL_BY_DATE_WARNING_THRESHOLD_DAYS + 1, 'days').toISOString(); const mockAssignmentConfigurationId = 'test-assignment-configuration-id'; const mockAssignment = { contentKey: 'edX+DemoX', @@ -383,6 +385,15 @@ describe('useContentAssignments', () => { uuid: 'test-assignment-uuid-4', state: ASSIGNMENT_TYPES.ACCEPTED, }; + const mockExpiringAssignment = { + ...mockAssignment, + uuid: 'test-assignment-uuid-5', + state: ASSIGNMENT_TYPES.ALLOCATED, + earliestPossibleExpiration: { + ...mockAssignment.earliestPossibleExpiration, + date: dayjs().add(ENROLL_BY_DATE_WARNING_THRESHOLD_DAYS - 1, 'days').toISOString(), + }, + }; const mockPoliciesWithAssignments = { ...mockRedeemableLearnerCreditPolicies, learnerContentAssignments: { @@ -416,6 +427,7 @@ describe('useContentAssignments', () => { beforeEach(() => { jest.clearAllMocks(); + localStorage.clear(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); }); @@ -566,6 +578,61 @@ describe('useContentAssignments', () => { .map((assignment) => assignment.uuid), }); }); + + it('should handle dismissal / acknowledgement of expiring assignments', async () => { + const mockPoliciesWithExpiringAssignments = { + ...mockPoliciesWithAssignments, + learnerContentAssignments: { + ...mockPoliciesWithAssignments.learnerContentAssignments, + assignmentsForDisplay: [ + ...mockPoliciesWithAssignments.learnerContentAssignments.assignmentsForDisplay, + mockExpiringAssignment, + ], + }, + }; + mockUseEnterpriseCourseEnrollments(mockPoliciesWithExpiringAssignments); + const { result } = renderHook(() => useContentAssignments(), { wrapper }); + const expectedAssignments = [ + { + uuid: mockExpiringAssignment.uuid, + courseRunStatus: COURSE_STATUSES.assigned, + enrollBy: mockExpiringAssignment.earliestPossibleExpiration.date, + title: mockExpiringAssignment.contentTitle, + isCanceledAssignment: false, + isExpiredAssignment: false, + isExpiringAssignment: true, + assignmentConfiguration: mockExpiringAssignment.assignmentConfiguration, + }, + { + uuid: mockAllocatedAssignment.uuid, + courseRunStatus: COURSE_STATUSES.assigned, + enrollBy: mockAllocatedAssignment.earliestPossibleExpiration.date, + title: mockAllocatedAssignment.contentTitle, + isCanceledAssignment: false, + isExpiredAssignment: false, + isExpiringAssignment: false, + assignmentConfiguration: mockAllocatedAssignment.assignmentConfiguration, + }, + ]; + expect(result.current).toEqual( + expect.objectContaining({ + assignments: expectedAssignments.map((assignment) => expect.objectContaining(assignment)), + showExpiringAssignmentsAlert: true, + handleAcknowledgeExpiringAssignments: expect.any(Function), + }), + ); + expect(global.localStorage.getItem(ASSIGNMENTS_EXPIRING_WARNING_LOCALSTORAGE_KEY)).toBeNull(); + + // Dismiss the expiring assignments alert and verify that localStorage is correctly updated. + act(() => { + result.current.handleAcknowledgeExpiringAssignments(); + }); + const acknowledgedExpiringAssignments = JSON.parse( + global.localStorage.getItem(ASSIGNMENTS_EXPIRING_WARNING_LOCALSTORAGE_KEY), + ); + expect(acknowledgedExpiringAssignments).toEqual([mockExpiringAssignment.uuid]); + expect(result.current.showExpiringAssignmentsAlert).toBe(false); + }); }); describe('useCourseEnrollmentsBySection', () => { diff --git a/src/components/dashboard/main-content/course-enrollments/data/utils.js b/src/components/dashboard/main-content/course-enrollments/data/utils.js index 45a86a18dc..269525f9e6 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/utils.js +++ b/src/components/dashboard/main-content/course-enrollments/data/utils.js @@ -20,7 +20,14 @@ export function sortAssignmentsByAssignmentStatus(assignments) { const sortedAssignments = assignmentsCopy.sort((a, b) => { const isAssignmentACanceledOrExpired = ['cancelled', 'expired'].includes(a.state) ? 1 : 0; const isAssignmentBCanceledOrExpired = ['cancelled', 'expired'].includes(b.state) ? 1 : 0; - return isAssignmentACanceledOrExpired - isAssignmentBCanceledOrExpired; + if (isAssignmentACanceledOrExpired && !isAssignmentBCanceledOrExpired) { + return 1; // a should come after b (expired/canceled assignments come last) + } + if (!isAssignmentACanceledOrExpired && isAssignmentBCanceledOrExpired) { + return -1; // b should come after a (expired/canceled assignments come last) + } + // If both assignments are not canceled or expired, sort by enroll-by date in ascending order + return dayjs(a.enrollBy).diff(dayjs(b.enrollBy)); }); return sortedAssignments; } diff --git a/src/components/dashboard/main-content/course-enrollments/tests/CourseEnrollments.test.jsx b/src/components/dashboard/main-content/course-enrollments/tests/CourseEnrollments.test.jsx index 4d32b46773..d698175e85 100644 --- a/src/components/dashboard/main-content/course-enrollments/tests/CourseEnrollments.test.jsx +++ b/src/components/dashboard/main-content/course-enrollments/tests/CourseEnrollments.test.jsx @@ -128,6 +128,7 @@ jest.mock('../data/utils', () => ({ const mockAcknowledgeAssignments = jest.fn(); const mockDismissGroupAssociationAlert = jest.fn(); +const mockHandleAcknowledgeExpiringAssignments = jest.fn(); describe('Course enrollments', () => { beforeEach(() => { @@ -178,6 +179,7 @@ describe('Course enrollments', () => { notifications: [], isCanceledAssignment: true, isExpiredAssignment: false, + isExpiringAssignment: false, endDate: dayjs().add(1, 'day').toISOString(), startDate: dayjs().subtract(1, 'day').toISOString(), mode: 'verified', @@ -213,6 +215,7 @@ describe('Course enrollments', () => { notifications: [], isCanceledAssignment: false, isExpiredAssignment: true, + isExpiringAssignment: false, endDate: dayjs().subtract(1, 'day').toISOString(), startDate: dayjs().subtract(30, 'day').toISOString(), mode: 'verified', @@ -236,40 +239,70 @@ describe('Course enrollments', () => { expect(mockAcknowledgeAssignments).toHaveBeenCalledWith({ assignmentState: ASSIGNMENT_TYPES.EXPIRED }); }); - it( - 'renders NewGroupAssignmentAlert when showNewGroupAssociationAlert is true', - async () => { - useEnterpriseFeatures.mockReturnValue({ data: { enterpriseGroupsV1: true } }); - hooks.useGroupAssociationsAlert.mockReturnValue({ - showNewGroupAssociationAlert: true, - dismissGroupAssociationAlert: mockDismissGroupAssociationAlert, - enterpriseCustomer: { - name: 'test-enterprise-customer', - }, - }); - renderWithRouter(); - const dismissButton = screen.getAllByRole('button', { name: 'Dismiss' })[0]; - userEvent.click(dismissButton); - expect(await screen.findByText('You have new courses to browse')).toBeInTheDocument(); - expect(mockDismissGroupAssociationAlert).toHaveBeenCalledTimes(1); - }, - ); - - it( - 'does not render NewGroupAssignmentAlert when showNewGroupAssociationAlert is false', - async () => { - hooks.useGroupAssociationsAlert.mockReturnValue({ - showNewGroupAssociationAlert: false, - dismissGroupAssociationAlert: mockDismissGroupAssociationAlert, - enterpriseCustomer: { - name: 'test-enterprise-customer', - }, - }); - renderWithRouter(); - expect(screen.queryByText('You have new courses to browse')).not.toBeInTheDocument(); - expect(mockDismissGroupAssociationAlert).not.toHaveBeenCalled(); - }, - ); + it('renders dismissible alert for expiring assignments and renders expiring assignment cards', async () => { + const mockCourseKey = 'test-courseKey'; + const mockAssignment = { + state: ASSIGNMENT_TYPES.ALLOCATED, + courseRunId: mockCourseKey, + courseRunStatus: COURSE_STATUSES.assigned, + title: 'test-title', + linkToCourse: `/test-enterprise/course/${mockCourseKey}`, + notifications: [], + isCanceledAssignment: false, + isExpiredAssignment: false, + isExpiringAssignment: true, + endDate: dayjs().subtract(1, 'day').toISOString(), + startDate: dayjs().subtract(30, 'day').toISOString(), + mode: 'verified', + }; + hooks.useContentAssignments.mockReturnValue({ + assignments: [mockAssignment], + showCanceledAssignmentsAlert: false, + showExpiredAssignmentsAlert: false, + showExpiringAssignmentsAlert: true, + handleAcknowledgeExpiringAssignments: mockHandleAcknowledgeExpiringAssignments, + }); + renderWithRouter(); + // Verify expiring assignment card is visible initially + expect(screen.getByText(mockAssignment.title)).toBeInTheDocument(); + // Verify expiring alert is visible initially + expect(screen.getByText('Enrollment deadlines approaching')).toBeInTheDocument(); + // Handles dismiss behavior + const dismissButton = screen.getByRole('button', { name: 'Dismiss' }); + userEvent.click(dismissButton); + await waitFor(() => { + expect(mockHandleAcknowledgeExpiringAssignments).toHaveBeenCalledTimes(1); + }); + }); + + it('renders NewGroupAssignmentAlert when showNewGroupAssociationAlert is true', async () => { + useEnterpriseFeatures.mockReturnValue({ data: { enterpriseGroupsV1: true } }); + hooks.useGroupAssociationsAlert.mockReturnValue({ + showNewGroupAssociationAlert: true, + dismissGroupAssociationAlert: mockDismissGroupAssociationAlert, + enterpriseCustomer: { + name: 'test-enterprise-customer', + }, + }); + renderWithRouter(); + const dismissButton = screen.getAllByRole('button', { name: 'Dismiss' })[0]; + userEvent.click(dismissButton); + expect(await screen.findByText('You have new courses to browse')).toBeInTheDocument(); + expect(mockDismissGroupAssociationAlert).toHaveBeenCalledTimes(1); + }); + + it('does not render NewGroupAssignmentAlert when showNewGroupAssociationAlert is false', async () => { + hooks.useGroupAssociationsAlert.mockReturnValue({ + showNewGroupAssociationAlert: false, + dismissGroupAssociationAlert: mockDismissGroupAssociationAlert, + enterpriseCustomer: { + name: 'test-enterprise-customer', + }, + }); + renderWithRouter(); + expect(screen.queryByText('You have new courses to browse')).not.toBeInTheDocument(); + expect(mockDismissGroupAssociationAlert).not.toHaveBeenCalled(); + }); it('generates course status update on move to in progress action', async () => { useLocation.mockReturnValue({ diff --git a/src/components/enterprise-user-subsidy/enterprise-offers/data/constants.js b/src/components/enterprise-user-subsidy/enterprise-offers/data/constants.js index fa7f56b3cc..e53b936a45 100644 --- a/src/components/enterprise-user-subsidy/enterprise-offers/data/constants.js +++ b/src/components/enterprise-user-subsidy/enterprise-offers/data/constants.js @@ -37,6 +37,7 @@ export const ASSIGNMENT_TYPES = { CANCELED: 'cancelled', EXPIRED: 'expired', ERRORED: 'errored', + EXPIRING: 'expiring', }; export const POLICY_TYPES = { ASSIGNED_CREDIT: 'AssignedLearnerCreditAccessPolicy', diff --git a/src/utils/common.js b/src/utils/common.js index adee0339d4..649325f8ed 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -150,3 +150,18 @@ export function getBrandColorsFromCSSVariables() { dark: getComputedStylePropertyCSSVariable('--pgn-color-dark'), }; } + +/** + * Helper function utilizing dayjs's 'isBetween' function to determine + * if the date passed is between today and an offset amount of days + * + * @param date + * @param days + * @returns {boolean} + */ +export function isTodayWithinDateThreshold({ date, days }) { + const dateToCheck = dayjs(date); + const today = dayjs(); + const offsetDays = dateToCheck.subtract(days, 'days'); + return today.isBetween(offsetDays, dateToCheck); +}