From 7ae824bd5eb082882aa6f816087f323d30a378ea Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Thu, 1 Feb 2024 15:27:48 -0500 Subject: [PATCH] feat: rely on API to determine expired state and acknowledge assignments (#932) --- .../data/hooks/useCourseRunCardAction.jsx | 2 +- src/components/dashboard/data/utils.js | 183 +-------------- .../CourseAssignmentAlert.jsx | 10 +- .../course-enrollments/CourseEnrollments.jsx | 23 +- .../course-enrollments/data/constants.js | 3 - .../course-enrollments/data/hooks.js | 159 +++++++------ .../course-enrollments/data/service.js | 13 +- .../data/tests/hooks.test.jsx | 212 ++++++++++-------- .../data/tests/service.test.js | 23 ++ .../data/tests/utils.test.js | 81 ------- .../course-enrollments/data/utils.js | 37 +-- .../tests/CourseEnrollments.test.jsx | 17 +- .../data/service.js | 3 +- .../data/tests/service.test.js | 7 + .../enterprise-user-subsidy/data/constants.js | 2 + .../data/hooks/hooks.js | 3 +- .../data/hooks/hooks.test.jsx | 74 +++--- .../enterprise-user-subsidy/data/utils.js | 82 ++++++- .../enterprise-offers/data/constants.js | 6 +- src/setupTest.js | 1 + 20 files changed, 444 insertions(+), 497 deletions(-) diff --git a/src/components/course/course-header/data/hooks/useCourseRunCardAction.jsx b/src/components/course/course-header/data/hooks/useCourseRunCardAction.jsx index f49eb24192..f0cc29a1a4 100644 --- a/src/components/course/course-header/data/hooks/useCourseRunCardAction.jsx +++ b/src/components/course/course-header/data/hooks/useCourseRunCardAction.jsx @@ -60,7 +60,7 @@ const useCourseRunCardAction = ({ const handleRedemptionSuccess = (transaction) => { if (!isUserEnrolled && !externalCourseEnrollmentUrl) { - toasts?.addToast(`You Enrolled in ${course.title}.`); + toasts?.addToast(`You enrolled in ${course.title}.`); } handleRedeemSuccess(transaction); }; diff --git a/src/components/dashboard/data/utils.js b/src/components/dashboard/data/utils.js index 17a213d3fa..96f9b016f7 100644 --- a/src/components/dashboard/data/utils.js +++ b/src/components/dashboard/data/utils.js @@ -1,77 +1,4 @@ -import dayjs from 'dayjs'; -import { ASSIGNMENT_TYPES, ASSIGNMENT_ACTION_TYPES } from '../../enterprise-user-subsidy/enterprise-offers/data/constants'; -import { - LEARNER_ACKNOWLEDGED_ASSIGNMENT_CANCELLATION_ALERT, - LEARNER_ACKNOWLEDGED_ASSIGNMENT_EXPIRATION_ALERT, -} from '../main-content/course-enrollments/data/constants'; - -/** - * Checks if an assignment has expired based on following conditions: - * - 90 days have passed since the "created" date. - * - The course enrollment deadline has passed. - * - The subsidy expiration date has passed. - * @param {object} assignment - Information about the assignment. - * @returns {boolean} - Returns true if the assignment has expired, otherwise false. - */ -export const isAssignmentExpired = (assignment) => { - if (!assignment) { - return { - isExpired: false, - enrollByDeadline: undefined, - }; - } - - const currentDate = dayjs(); - // Note: `created` is not currently present in the API response for assignments. In the future, - // the enroll by deadline will be returned by API instead of calculating it here. - const allocationDate = assignment.created ? dayjs(assignment.created) : undefined; - const enrollmentEndDate = assignment.contentMetadata.enrollByDate - ? dayjs(assignment.contentMetadata.enrollByDate) - : undefined; - const subsidyExpirationDate = dayjs(assignment.subsidyExpirationDate); - - const hasExceededAssignmentDeadline = allocationDate && currentDate.diff(allocationDate, 'day') > 90; - const isEnrollmentDeadlineExpired = enrollmentEndDate && currentDate.isAfter(enrollmentEndDate); - - const isExpired = ( - hasExceededAssignmentDeadline || isEnrollmentDeadlineExpired || currentDate.isAfter(subsidyExpirationDate) - ); - - const assignmentExpiryDates = [subsidyExpirationDate]; - if (enrollmentEndDate) { - assignmentExpiryDates.push(enrollmentEndDate); - } - if (allocationDate) { - assignmentExpiryDates.push(dayjs(allocationDate).add(90, 'day')); - } - const earliestAssignmentExpiryDate = assignmentExpiryDates.sort((a, b) => (dayjs(a).isAfter(b) ? 1 : -1))[0].toDate(); - - return { - isExpired, - enrollByDeadline: earliestAssignmentExpiryDate, - }; -}; - -/** - * Determines whether an assignment record is expired and/or the expiration has been acknowledged by the learner. - * - * @param {Object} assignment - Metadata about the assignment. - * @returns {Object} - Returns an object with the following properties: - * - isExpired: Boolean indicating whether the assignment has expired. - * - hasDismissedExpiration: Boolean indicating whether the learner has acknowledged the assignment expiration. - */ -export function isExpiredAssignmentAcknowledged(assignment) { - const lastExpiredAlertDismissedTime = global.localStorage.getItem( - LEARNER_ACKNOWLEDGED_ASSIGNMENT_EXPIRATION_ALERT, - ); - const { isExpired, enrollByDeadline } = isAssignmentExpired(assignment); - const isAcknowledged = dayjs(enrollByDeadline).isBefore(new Date(lastExpiredAlertDismissedTime)); - const hasDismissedExpiration = isExpired && isAcknowledged; - return { - isExpired, - hasDismissedExpiration, - }; -} +import { ASSIGNMENT_TYPES } from '../../enterprise-user-subsidy/enterprise-offers/data/constants'; /** * Determines whether there are any unacknowledged expired assignments. @@ -80,37 +7,9 @@ export function isExpiredAssignmentAcknowledged(assignment) { * @returns {Boolean} - Returns true if there are any unacknowledged expired assignments, otherwise false. */ export function getHasUnacknowledgedExpiredAssignments(assignments) { - return assignments.some((assignment) => { - const { isExpired, hasDismissedExpiration } = isExpiredAssignmentAcknowledged(assignment); - return isExpired && !hasDismissedExpiration; - }); -} - -/** - * Determines whether an assignment has been canceled and/or the cancelaton has been acknowledged by the learner. - * - * @param {Object} assignment - Metadata about the assignment. - * @returns {Object} - Returns an object with the following properties: - * - isCanceled: Boolean indicating whether the assignment has been canceled. - * - hasDismissedCancellation: Boolean indicating whether the learner has acknowledged the assignment cancellation. - */ -export function isCanceledAssignmentAcknowledged(assignment) { - const lastCanceledAlertDismissedTime = global.localStorage.getItem( - LEARNER_ACKNOWLEDGED_ASSIGNMENT_CANCELLATION_ALERT, - ); - const isCanceled = assignment.state === ASSIGNMENT_TYPES.CANCELED; - const hasDismissedCancelation = assignment.actions.some((action) => { - const isCanceledNoticationAction = [ - ASSIGNMENT_ACTION_TYPES.CANCELED, - ASSIGNMENT_ACTION_TYPES.AUTOMATIC_CANCELATION, - ].includes(action.actionType); - const isAcknowledged = dayjs(action.completedAt).isBefore(new Date(lastCanceledAlertDismissedTime)); - return isCanceled && isCanceledNoticationAction && isAcknowledged; - }); - return { - isCanceled, - hasDismissedCancelation, - }; + return assignments.some((assignment) => ( + assignment.state === ASSIGNMENT_TYPES.EXPIRED && !assignment.learnerAcknowledged + )); } /** @@ -120,75 +19,7 @@ export function isCanceledAssignmentAcknowledged(assignment) { * @returns {Boolean} - Returns true if there are any unacknowledged canceled assignments, otherwise false. */ export function getHasUnacknowledgedCanceledAssignments(assignments) { - return assignments.some((assignment) => { - const { isCanceled, hasDismissedCancelation } = isCanceledAssignmentAcknowledged(assignment); - return isCanceled && !hasDismissedCancelation; - }); -} - -/** - * Takes a flattened array of assignments and returns an object containing - * lists of assignments for each assignment state. - * - * @param {Array} assignments - List of content assignments. - * @returns {{ - * assignments: Array, - * hasAssignments: Boolean, - * allocatedAssignments: Array, - * hasAllocatedAssignments: Boolean, - * canceledAssignments: Array, - * hasCanceledAssignments: Boolean, - * acceptedAssignments: Array, - * hasAcceptedAssignments: Boolean, - * }} - */ -export function getAssignmentsByState(assignments = []) { - const allAssignments = []; - const allocatedAssignments = []; - const canceledAssignments = []; - const acceptedAssignments = []; - const erroredAssignments = []; - const assignmentsForDisplay = []; - - assignments.forEach((assignment) => { - allAssignments.push(assignment); - if (assignment.state === ASSIGNMENT_TYPES.ALLOCATED) { - allocatedAssignments.push(assignment); - } - if (assignment.state === ASSIGNMENT_TYPES.CANCELED) { - canceledAssignments.push(assignment); - } - if (assignment.state === ASSIGNMENT_TYPES.ACCEPTED) { - acceptedAssignments.push(assignment); - } - if (assignment.state === ASSIGNMENT_TYPES.ERRORED) { - erroredAssignments.push(assignment); - } - }); - - const hasAssignments = allAssignments.length > 0; - const hasAllocatedAssignments = allocatedAssignments.length > 0; - const hasCanceledAssignments = canceledAssignments.length > 0; - const hasAcceptedAssignments = acceptedAssignments.length > 0; - const hasErroredAssignments = erroredAssignments.length > 0; - - // Concatenate all assignments for display (includes allocated and canceled assignments) - assignmentsForDisplay.push(...allocatedAssignments); - assignmentsForDisplay.push(...canceledAssignments); - const hasAssignmentsForDisplay = assignmentsForDisplay.length > 0; - - return { - assignments, - hasAssignments, - allocatedAssignments, - hasAllocatedAssignments, - canceledAssignments, - hasCanceledAssignments, - acceptedAssignments, - hasAcceptedAssignments, - erroredAssignments, - hasErroredAssignments, - assignmentsForDisplay, - hasAssignmentsForDisplay, - }; + return assignments.some((assignment) => ( + assignment.state === ASSIGNMENT_TYPES.CANCELED && !assignment.learnerAcknowledged + )); } diff --git a/src/components/dashboard/main-content/course-enrollments/CourseAssignmentAlert.jsx b/src/components/dashboard/main-content/course-enrollments/CourseAssignmentAlert.jsx index 7f1c7480bd..98ffad661f 100644 --- a/src/components/dashboard/main-content/course-enrollments/CourseAssignmentAlert.jsx +++ b/src/components/dashboard/main-content/course-enrollments/CourseAssignmentAlert.jsx @@ -1,10 +1,12 @@ import React, { useContext } from 'react'; -import { AppContext } from '@edx/frontend-platform/react'; import PropTypes from 'prop-types'; +import { AppContext } from '@edx/frontend-platform/react'; import { Alert, Button, MailtoLink } from '@edx/paragon'; import { Info } from '@edx/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'; const CourseAssignmentAlert = ({ showAlert, @@ -12,7 +14,7 @@ const CourseAssignmentAlert = ({ variant, }) => { const intl = useIntl(); - const heading = variant === 'canceled' ? ( + const heading = variant === ASSIGNMENT_TYPES.CANCELED ? ( ); - const text = variant === 'canceled' ? ( + const text = variant === ASSIGNMENT_TYPES.CANCELED ? ( { const { redeemableLearnerCreditPolicies } = useContext(UserSubsidyContext); @@ -28,8 +28,7 @@ const CourseEnrollments = ({ children }) => { assignments, showCanceledAssignmentsAlert, showExpiredAssignmentsAlert, - handleOnCloseCancelAlert, - handleOnCloseExpiredAlert, + handleAcknowledgeAssignments, } = useContentAssignments(redeemableLearnerCreditPolicies); const { hasCourseEnrollments, @@ -67,10 +66,22 @@ const CourseEnrollments = ({ children }) => { return ( <> {features.FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT && ( - + handleAcknowledgeAssignments({ + assignmentState: ASSIGNMENT_TYPES.CANCELED, + })} + /> )} {features.FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT && ( - + handleAcknowledgeAssignments({ + assignmentState: ASSIGNMENT_TYPES.EXPIRED, + })} + /> )} {showMarkCourseCompleteSuccess && ( setShowMarkCourseCompleteSuccess(false)}> diff --git a/src/components/dashboard/main-content/course-enrollments/data/constants.js b/src/components/dashboard/main-content/course-enrollments/data/constants.js index bd77f72f1a..f6e6ff777a 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/constants.js +++ b/src/components/dashboard/main-content/course-enrollments/data/constants.js @@ -19,6 +19,3 @@ export const COURSE_STATUSES = { }; export const GETSMARTER_BASE_URL = 'https://www.getsmarter.com'; - -export const LEARNER_ACKNOWLEDGED_ASSIGNMENT_CANCELLATION_ALERT = 'learnerAcknowledgedCancellationAt'; -export const LEARNER_ACKNOWLEDGED_ASSIGNMENT_EXPIRATION_ALERT = 'learnerAcknowledgedExpirationAt'; diff --git a/src/components/dashboard/main-content/course-enrollments/data/hooks.js b/src/components/dashboard/main-content/course-enrollments/data/hooks.js index 6b01530147..c6902b3d72 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/hooks.js +++ b/src/components/dashboard/main-content/course-enrollments/data/hooks.js @@ -1,9 +1,10 @@ import { useCallback, useContext, useEffect, useMemo, useState, } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AppContext } from '@edx/frontend-platform/react'; import { camelCaseObject } from '@edx/frontend-platform/utils'; -import { logError } from '@edx/frontend-platform/logging'; +import { logError, logInfo } from '@edx/frontend-platform/logging'; import _camelCase from 'lodash.camelcase'; import _cloneDeep from 'lodash.clonedeep'; @@ -15,11 +16,7 @@ import { sortedEnrollmentsByEnrollmentDate, transformCourseEnrollment, } from './utils'; -import { - COURSE_STATUSES, - LEARNER_ACKNOWLEDGED_ASSIGNMENT_CANCELLATION_ALERT, - LEARNER_ACKNOWLEDGED_ASSIGNMENT_EXPIRATION_ALERT, -} from './constants'; +import { COURSE_STATUSES } from './constants'; import CourseService from '../../../../course/data/service'; import { createEnrollWithCouponCodeUrl, @@ -31,10 +28,9 @@ import { import { getHasUnacknowledgedCanceledAssignments, getHasUnacknowledgedExpiredAssignments, - isCanceledAssignmentAcknowledged, - isExpiredAssignmentAcknowledged, } from '../../../data/utils'; import { ASSIGNMENT_TYPES } from '../../../../enterprise-user-subsidy/enterprise-offers/data/constants'; +import { enterpriseUserSubsidyQueryKeys } from '../../../../enterprise-user-subsidy/data/constants'; export const useCourseEnrollments = ({ enterpriseUUID, @@ -205,6 +201,40 @@ export const useCourseUpgradeData = ({ }; }; +export function useAcknowledgeContentAssignments({ + enterpriseId, + userId, +}) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + assignmentsByAssignmentConfiguration, + }) => { + const promisesToAcknowledge = []; + Object.entries(assignmentsByAssignmentConfiguration).forEach( + ([assignmentConfiguration, assignmentsForConfiguration]) => { + const assignmentIds = assignmentsForConfiguration.map(assignment => assignment.uuid); + promisesToAcknowledge.push( + service.acknowledgeContentAssignments({ + assignmentConfigurationId: assignmentConfiguration, + assignmentIds, + }), + ); + }, + ); + const responses = await Promise.all(promisesToAcknowledge); + return responses.map(response => camelCaseObject(response.data)); + }, + onSuccess: () => { + // Invalidate the query for the redeemable policies in order to trigger refetch of `credits_available` API, + // returning the updated assignments list per policy, excluding now-acknowledged assignments. + queryClient.invalidateQueries({ + queryKey: enterpriseUserSubsidyQueryKeys.redeemablePolicies(enterpriseId, userId), + }); + }, + }); +} + /** * - Parses list of redeemable learner credit policies to extract a list of learner content * assignments across all policies. @@ -218,39 +248,67 @@ export const useCourseUpgradeData = ({ * - assignments: Array of transformed assignments for display. * - showCanceledAssignmentsAlert: Boolean indicating whether to display the canceled assignments alert. * - showExpiredAssignmentsAlert: Boolean indicating whether to display the expired assignments alert. - * - handleOnCloseCancelAlert: Function to handle dismissal of the canceled assignments alert. - * - handleOnCloseExpiredAlert: Function to handle dismissal of the expired assignments alert. + * - handleAcknowledgeAssignments: Function to handle dismissal of canceled/expired assignments from the dashboard. */ export function useContentAssignments(redeemableLearnerCreditPolicies) { const { - enterpriseConfig: { slug: enterpriseSlug }, + enterpriseConfig: { + uuid: enterpriseId, + slug: enterpriseSlug, + }, + authenticatedUser: { userId }, } = useContext(AppContext); const [assignments, setAssignments] = useState([]); const [showCanceledAssignmentsAlert, setShowCanceledAssignmentsAlert] = useState(false); const [showExpiredAssignmentsAlert, setShowExpiredAssignmentsAlert] = useState(false); - /** - * On dismiss of the canceled assignments alert, remove all canceled - * assignments from the displayed list of assignments. Set the localStorage - * key to the current date of the acknowledgement. - */ - const handleOnCloseCancelAlert = useCallback(() => { - setAssignments((prevState) => prevState.filter((assignment) => !assignment.isCanceledAssignment)); - setShowCanceledAssignmentsAlert(false); - global.localStorage.setItem(LEARNER_ACKNOWLEDGED_ASSIGNMENT_CANCELLATION_ALERT, new Date()); - }, []); + const { + mutate, + isLoading: isLoadingMutation, + } = useAcknowledgeContentAssignments({ enterpriseId, userId }); + + const handleAcknowledgeAssignments = useCallback(({ assignmentState }) => { + const assignmentStateMap = { + [ASSIGNMENT_TYPES.CANCELED]: 'isCanceledAssignment', + [ASSIGNMENT_TYPES.EXPIRED]: 'isExpiredAssignment', + }; - /** - * On dismiss of the expired assignments alert, remove all expired - * assignments from the displayed list of assignments. Set the localStorage - * key to the current date of the acknowledgement. - */ - const handleOnCloseExpiredAlert = useCallback(() => { - setAssignments((prevState) => prevState.filter((assignment) => !assignment.isExpiredAssignment)); - setShowExpiredAssignmentsAlert(false); - global.localStorage.setItem(LEARNER_ACKNOWLEDGED_ASSIGNMENT_EXPIRATION_ALERT, new Date()); - }, []); + // Fail early if mutation is already in progress. + if (isLoadingMutation) { + logInfo('Attempted to acknowledge assignments while mutation is in progress.'); + return; + } + + // Invalid assignment state passed to function. + const assignmentStateProperty = assignmentStateMap[assignmentState]; + if (!assignmentStateProperty) { + logError(`Invalid assignment state (${assignmentState}) passed to handleAcknowledgeAssignments.`); + return; + } + + // Otherwise, perform the mutation to acknowledge assignments. + const assignmentsByAssignmentConfiguration = {}; + assignments.forEach((assignment) => { + const { assignmentConfiguration } = assignment; + const isRequestedStateActive = !!assignment[assignmentStateProperty]; + + // Check whether assignment is in requested state. If not, skip. + if (!isRequestedStateActive) { + return; + } + + // Initialize assignment list for AssignmentConfiguration, if necessary. + if (!assignmentsByAssignmentConfiguration[assignmentConfiguration]) { + assignmentsByAssignmentConfiguration[assignmentConfiguration] = []; + } + // Append the assignment to the AssignmentConfiguration. + assignmentsByAssignmentConfiguration[assignmentConfiguration].push(assignment); + }); + + // POST to `acknowledge-assignments` API for each AssignmentConfiguration. + mutate({ assignmentsByAssignmentConfiguration }); + }, [mutate, assignments, isLoadingMutation]); /** * Parses the learner content assignments from the redeemableLearnerCreditPolicies @@ -259,41 +317,13 @@ export function useContentAssignments(redeemableLearnerCreditPolicies) { */ useEffect(() => { const { - allocatedAssignments, - canceledAssignments, assignmentsForDisplay, + canceledAssignments, + expiredAssignments, } = redeemableLearnerCreditPolicies.learnerContentAssignments; - const lastCanceledAlertDismissedTime = global.localStorage.getItem( - LEARNER_ACKNOWLEDGED_ASSIGNMENT_CANCELLATION_ALERT, - ); - const lastExpiredAlertDismissedTime = global.localStorage.getItem( - LEARNER_ACKNOWLEDGED_ASSIGNMENT_EXPIRATION_ALERT, - ); - - const filteredAssignmentsForDisplay = assignmentsForDisplay.filter((assignment) => { - // Filter out already-dismissed canceled assignments - if (lastCanceledAlertDismissedTime) { - const { isCanceled, hasDismissedCancelation } = isCanceledAssignmentAcknowledged(assignment); - if (isCanceled && hasDismissedCancelation) { - return false; - } - } - - // Filter out already-dismissed expired assignments - if (lastExpiredAlertDismissedTime) { - const { isExpired, hasDismissedExpiration } = isExpiredAssignmentAcknowledged(assignment); - if (isExpired && hasDismissedExpiration) { - return false; - } - } - - // No canceled/expired assignments have been acknowledged (dismissed) yet; keep assignment for display. - return true; - }); - // Sort and transform the list of assignments for display. - const sortedAssignmentsForDisplay = sortAssignmentsByAssignmentStatus(filteredAssignmentsForDisplay); + const sortedAssignmentsForDisplay = sortAssignmentsByAssignmentStatus(assignmentsForDisplay); const transformedAssignmentsForDisplay = getTransformedAllocatedAssignments( sortedAssignmentsForDisplay, enterpriseSlug, @@ -305,7 +335,7 @@ export function useContentAssignments(redeemableLearnerCreditPolicies) { setShowCanceledAssignmentsAlert(hasUnacknowledgedCanceledAssignments); // Determine whether there are unacknowledged expired assignments. If so, display alert. - const hasUnacknowledgedExpiredAssignments = getHasUnacknowledgedExpiredAssignments(allocatedAssignments); + const hasUnacknowledgedExpiredAssignments = getHasUnacknowledgedExpiredAssignments(expiredAssignments); setShowExpiredAssignmentsAlert(hasUnacknowledgedExpiredAssignments); }, [redeemableLearnerCreditPolicies, enterpriseSlug]); @@ -313,8 +343,7 @@ export function useContentAssignments(redeemableLearnerCreditPolicies) { assignments, showCanceledAssignmentsAlert, showExpiredAssignmentsAlert, - handleOnCloseCancelAlert, - handleOnCloseExpiredAlert, + handleAcknowledgeAssignments, }; } diff --git a/src/components/dashboard/main-content/course-enrollments/data/service.js b/src/components/dashboard/main-content/course-enrollments/data/service.js index 5232ec3595..98cada0924 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/service.js +++ b/src/components/dashboard/main-content/course-enrollments/data/service.js @@ -6,7 +6,16 @@ export const fetchEnterpriseCourseEnrollments = (uuid) => { enterprise_id: uuid, is_active: true, }); - const config = getConfig(); - const url = `${config.LMS_BASE_URL}/enterprise_learner_portal/api/v1/enterprise_course_enrollments/?${queryParams.toString()}`; + const url = `${getConfig().LMS_BASE_URL}/enterprise_learner_portal/api/v1/enterprise_course_enrollments/?${queryParams.toString()}`; return getAuthenticatedHttpClient().get(url); }; + +export const acknowledgeContentAssignments = ({ + assignmentConfigurationId, + assignmentIds, +}) => { + const url = `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/assignment-configurations/${assignmentConfigurationId}/acknowledge-assignments/`; + return getAuthenticatedHttpClient().post(url, { + assignment_uuids: assignmentIds, + }); +}; 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 7d2743f6a2..4ef6758c63 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 @@ -3,6 +3,7 @@ import * as logger from '@edx/frontend-platform/logging'; import { AppContext } from '@edx/frontend-platform/react'; import camelCase from 'lodash.camelcase'; import dayjs from 'dayjs'; +import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { useContentAssignments, @@ -11,16 +12,17 @@ import { useCourseUpgradeData, } from '../hooks'; import * as service from '../service'; -import { COURSE_STATUSES, LEARNER_ACKNOWLEDGED_ASSIGNMENT_CANCELLATION_ALERT, LEARNER_ACKNOWLEDGED_ASSIGNMENT_EXPIRATION_ALERT } from '../constants'; +import { COURSE_STATUSES } from '../constants'; import { transformCourseEnrollment } from '../utils'; import { createRawCourseEnrollment } from '../../tests/enrollment-testutils'; import { createEnrollWithLicenseUrl, createEnrollWithCouponCodeUrl } from '../../../../../course/data/utils'; -import { ASSIGNMENT_ACTION_TYPES, ASSIGNMENT_TYPES } from '../../../../../enterprise-user-subsidy/enterprise-offers/data/constants'; +import { ASSIGNMENT_TYPES } from '../../../../../enterprise-user-subsidy/enterprise-offers/data/constants'; import { emptyRedeemableLearnerCreditPolicies } from '../../../../../enterprise-user-subsidy/data/constants'; jest.mock('../service'); jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn(), + logInfo: jest.fn(), })); const mockCourseService = { @@ -311,21 +313,33 @@ describe('useContentAssignments', () => { enterpriseConfig: { slug: 'test-enterprise', }, + authenticatedUser: { + userId: 3, + }, }; const wrapper = ({ children }) => ( - - {children} - + + + {children} + + ); const mockRedeemableLearnerCreditPolicies = emptyRedeemableLearnerCreditPolicies; + const mockSubsidyExpirationDateStr = dayjs().add(1, 'd').toISOString(); + const mockAssignmentConfigurationId = 'test-assignment-configuration-id'; const mockAssignment = { contentKey: 'edX+DemoX', contentTitle: 'edX Demo Course', - subsidyExpirationDate: dayjs().add(1, 'w').toISOString(), + subsidyExpirationDate: mockSubsidyExpirationDateStr, + assignmentConfiguration: mockAssignmentConfigurationId, contentMetadata: { - enrollByDate: dayjs().add(1, 'd').toISOString(), + enrollByDate: dayjs().add(1, 'w').toISOString(), partners: [{ name: 'Test Partner' }], }, + earliestPossibleExpiration: { + date: mockSubsidyExpirationDateStr, + reason: 'subsidy_expired', + }, actions: [], }; const mockAllocatedAssignment = { @@ -333,20 +347,17 @@ describe('useContentAssignments', () => { uuid: 'test-assignment-uuid', state: ASSIGNMENT_TYPES.ALLOCATED, }; - const mockAllocatedExpiredAssignment = { - ...mockAllocatedAssignment, + const mockExpiredAssignment = { + ...mockAssignment, uuid: 'test-assignment-uuid-2', - contentMetadata: { - ...mockAllocatedAssignment.contentMetadata, - enrollByDate: dayjs().subtract(1, 'w').toISOString(), - }, + state: ASSIGNMENT_TYPES.EXPIRED, }; const mockCanceledAssignment = { ...mockAssignment, uuid: 'test-assignment-uuid-3', state: ASSIGNMENT_TYPES.CANCELED, actions: [{ - actionType: ASSIGNMENT_ACTION_TYPES.CANCELED, + actionType: ASSIGNMENT_TYPES.CANCELED, completedAt: dayjs().subtract(1, 'w').toISOString(), }], }; @@ -361,143 +372,156 @@ describe('useContentAssignments', () => { ...mockRedeemableLearnerCreditPolicies.learnerContentAssignments, allocatedAssignments: [mockAllocatedAssignment], hasAllocatedAssignments: true, - canceledAssignments: [mockCanceledAssignment], - hasCanceledAssignments: true, acceptedAssignments: [mockAcceptedAssignment], hasAcceptedAssignments: true, - assignmentsForDisplay: [mockAllocatedAssignment, mockCanceledAssignment], + assignmentsForDisplay: [mockAllocatedAssignment], hasAssignmentsForDisplay: true, }, }; - afterEach(() => { - window.localStorage.removeItem(LEARNER_ACKNOWLEDGED_ASSIGNMENT_CANCELLATION_ALERT); - window.localStorage.removeItem(LEARNER_ACKNOWLEDGED_ASSIGNMENT_EXPIRATION_ALERT); + beforeEach(() => { + jest.clearAllMocks(); }); - it.each([ - { hasDismissedCanceledAssignmentsAlert: false }, - { hasDismissedCanceledAssignmentsAlert: true }, - ])('should return only allocated and canceled assignments, handling dismiss behavior (%s)', async ({ - hasDismissedCanceledAssignmentsAlert, - }) => { - if (hasDismissedCanceledAssignmentsAlert) { - window.localStorage.setItem(LEARNER_ACKNOWLEDGED_ASSIGNMENT_CANCELLATION_ALERT, new Date()); - } + it('should do nothing if acknowledgeContentAssignments called with unsupported assignment state', async () => { const { result } = renderHook( () => useContentAssignments(mockPoliciesWithAssignments), { wrapper }, ); + expect(result.current.handleAcknowledgeAssignments).toBeInstanceOf(Function); + result.current.handleAcknowledgeAssignments({ assignmentState: 'invalid' }); + expect(logger.logError).toHaveBeenCalledWith('Invalid assignment state (invalid) passed to handleAcknowledgeAssignments.'); + expect(service.acknowledgeContentAssignments).not.toHaveBeenCalled(); + }); + + it('should handle dismissal / acknowledgement of cancelled assignments', async () => { + service.acknowledgeContentAssignments.mockResolvedValue({ + data: { + acknowledged_assignments: [], + already_acknowledged_assignments: [], + unacknowledged_assignments: [], + }, + }); + const mockPoliciesWithCanceledAssignments = { + ...mockPoliciesWithAssignments, + learnerContentAssignments: { + ...mockPoliciesWithAssignments.learnerContentAssignments, + canceledAssignments: [mockCanceledAssignment], + hasCanceledAssignments: true, + assignmentsForDisplay: [ + ...mockPoliciesWithAssignments.learnerContentAssignments.assignmentsForDisplay, + mockCanceledAssignment, + ], + }, + }; + const { result, waitForNextUpdate } = renderHook( + () => useContentAssignments(mockPoliciesWithCanceledAssignments), + { wrapper }, + ); const expectedAssignments = [ { + uuid: mockAllocatedAssignment.uuid, courseRunStatus: COURSE_STATUSES.assigned, - enrollBy: dayjs(mockAllocatedAssignment.contentMetadata.enrollByDate).toDate(), + enrollBy: dayjs(mockAllocatedAssignment.earliestPossibleExpiration.date).toISOString(), title: mockAllocatedAssignment.contentTitle, isCanceledAssignment: false, isExpiredAssignment: false, + assignmentConfiguration: mockAllocatedAssignment.assignmentConfiguration, }, - ]; - if (!hasDismissedCanceledAssignmentsAlert) { - expectedAssignments.push({ + { + uuid: mockCanceledAssignment.uuid, courseRunStatus: COURSE_STATUSES.assigned, - enrollBy: dayjs(mockCanceledAssignment.contentMetadata.enrollByDate).toDate(), + enrollBy: dayjs(mockCanceledAssignment.earliestPossibleExpiration.date).toISOString(), title: mockCanceledAssignment.contentTitle, isCanceledAssignment: true, isExpiredAssignment: false, - }); - } + assignmentConfiguration: mockCanceledAssignment.assignmentConfiguration, + }, + ]; + expect(result.current).toEqual( expect.objectContaining({ assignments: expectedAssignments.map((assignment) => expect.objectContaining(assignment)), - showCanceledAssignmentsAlert: !hasDismissedCanceledAssignmentsAlert, + showCanceledAssignmentsAlert: true, showExpiredAssignmentsAlert: false, - handleOnCloseCancelAlert: expect.any(Function), - handleOnCloseExpiredAlert: expect.any(Function), + handleAcknowledgeAssignments: expect.any(Function), }), ); - // If canceled assignments have not yet been dismissed/acknowledged, then - // dismiss the canceled assignments alert and verify that the canceled - // assignments are no longer returned. - if (!hasDismissedCanceledAssignmentsAlert) { - act(() => result.current.handleOnCloseCancelAlert()); - expect(window.localStorage.getItem(LEARNER_ACKNOWLEDGED_ASSIGNMENT_CANCELLATION_ALERT)).toBeTruthy(); - expect(result.current).toEqual( - expect.objectContaining({ - assignments: expectedAssignments - .filter((assignment) => !assignment.isCanceledAssignment) - .map((assignment) => expect.objectContaining(assignment)), - showCanceledAssignmentsAlert: false, - }), - ); - } + // Dismiss the canceled assignments alert and verify the `credits_available` query cache is invalidated. + result.current.handleAcknowledgeAssignments({ assignmentState: ASSIGNMENT_TYPES.CANCELED }); + await waitForNextUpdate(); + expect(service.acknowledgeContentAssignments).toHaveBeenCalledTimes(1); + expect(service.acknowledgeContentAssignments).toHaveBeenCalledWith({ + assignmentConfigurationId: mockAssignmentConfigurationId, + assignmentIds: expectedAssignments + .filter((assignment) => assignment.isCanceledAssignment) + .map((assignment) => assignment.uuid), + }); }); - it.each([ - { hasDismissedExpiredAssignmentsAlert: false }, - { hasDismissedExpiredAssignmentsAlert: true }, - ])('should return only allocated and expired assignments, handling dismiss behavior (%s)', async ({ - hasDismissedExpiredAssignmentsAlert, - }) => { + it('should handle dismissal / acknowledgement of expired assignments', async () => { + service.acknowledgeContentAssignments.mockReturnValue({ + data: { + acknowledged_assignments: [], + already_acknowledged_assignments: [], + unacknowledged_assignments: [], + }, + }); const mockPoliciesWithExpiredAssignments = { ...mockPoliciesWithAssignments, learnerContentAssignments: { ...mockPoliciesWithAssignments.learnerContentAssignments, - allocatedAssignments: [mockAllocatedAssignment, mockAllocatedExpiredAssignment], - hasAllocatedAssignments: true, - assignmentsForDisplay: [mockAllocatedAssignment, mockAllocatedExpiredAssignment], - hasAssignmentsForDisplay: true, + expiredAssignments: [mockExpiredAssignment], + hasExpiredAssignments: true, + assignmentsForDisplay: [ + ...mockPoliciesWithAssignments.learnerContentAssignments.assignmentsForDisplay, + mockExpiredAssignment, + ], }, }; - if (hasDismissedExpiredAssignmentsAlert) { - window.localStorage.setItem(LEARNER_ACKNOWLEDGED_ASSIGNMENT_EXPIRATION_ALERT, new Date()); - } - const { result } = renderHook( + const { result, waitForNextUpdate } = renderHook( () => useContentAssignments(mockPoliciesWithExpiredAssignments), { wrapper }, ); const expectedAssignments = [ { + uuid: mockAllocatedAssignment.uuid, courseRunStatus: COURSE_STATUSES.assigned, - enrollBy: dayjs(mockAllocatedAssignment.contentMetadata.enrollByDate).toDate(), + enrollBy: dayjs(mockAllocatedAssignment.earliestPossibleExpiration.date).toISOString(), title: mockAllocatedAssignment.contentTitle, isCanceledAssignment: false, isExpiredAssignment: false, + assignmentConfiguration: mockAllocatedAssignment.assignmentConfiguration, }, - ]; - if (!hasDismissedExpiredAssignmentsAlert) { - expectedAssignments.push({ + { + uuid: mockExpiredAssignment.uuid, courseRunStatus: COURSE_STATUSES.assigned, - enrollBy: dayjs(mockAllocatedExpiredAssignment.contentMetadata.enrollByDate).toDate(), - title: mockAllocatedExpiredAssignment.contentTitle, + enrollBy: dayjs(mockExpiredAssignment.earliestPossibleExpiration.date).toISOString(), + title: mockExpiredAssignment.contentTitle, isCanceledAssignment: false, isExpiredAssignment: true, - }); - } + assignmentConfiguration: mockExpiredAssignment.assignmentConfiguration, + }, + ]; expect(result.current).toEqual( expect.objectContaining({ assignments: expectedAssignments.map((assignment) => expect.objectContaining(assignment)), - showExpiredAssignmentsAlert: !hasDismissedExpiredAssignmentsAlert, - handleOnCloseCancelAlert: expect.any(Function), - handleOnCloseExpiredAlert: expect.any(Function), + showExpiredAssignmentsAlert: true, + handleAcknowledgeAssignments: expect.any(Function), }), ); - // If expired assignments have not yet been dismissed/acknowledged, then - // dismiss the expired assignments alert and verify that the expired - // assignments are no longer returned. - if (!hasDismissedExpiredAssignmentsAlert) { - act(() => result.current.handleOnCloseExpiredAlert()); - expect(window.localStorage.getItem(LEARNER_ACKNOWLEDGED_ASSIGNMENT_EXPIRATION_ALERT)).toBeTruthy(); - expect(result.current).toEqual( - expect.objectContaining({ - assignments: expectedAssignments - .filter((assignment) => !assignment.isExpiredAssignment) - .map((assignment) => expect.objectContaining(assignment)), - showExpiredAssignmentsAlert: false, - }), - ); - } + // Dismiss the expired assignments alert and verify that the `credits_available` query cache is invalidated. + result.current.handleAcknowledgeAssignments({ assignmentState: ASSIGNMENT_TYPES.EXPIRED }); + await waitForNextUpdate(); + expect(service.acknowledgeContentAssignments).toHaveBeenCalledTimes(1); + expect(service.acknowledgeContentAssignments).toHaveBeenCalledWith({ + assignmentConfigurationId: mockAssignmentConfigurationId, + assignmentIds: expectedAssignments + .filter((assignment) => assignment.isExpiredAssignment) + .map((assignment) => assignment.uuid), + }); }); }); diff --git a/src/components/dashboard/main-content/course-enrollments/data/tests/service.test.js b/src/components/dashboard/main-content/course-enrollments/data/tests/service.test.js index 1ed4da7ff4..21068e0f1f 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/tests/service.test.js +++ b/src/components/dashboard/main-content/course-enrollments/data/tests/service.test.js @@ -1,8 +1,10 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { mergeConfig } from '@edx/frontend-platform/config'; import { + acknowledgeContentAssignments, fetchEnterpriseCourseEnrollments, } from '../service'; @@ -11,11 +13,32 @@ const axiosMock = new MockAdapter(axios); getAuthenticatedHttpClient.mockReturnValue(axios); axiosMock.onAny().reply(200); axios.get = jest.fn(); +axios.post = jest.fn(); describe('course enrollments service', () => { + beforeEach(() => { + mergeConfig({ + ENTERPRISE_ACCESS_BASE_URL: process.env.ENTERPRISE_ACCESS_BASE_URL, + }); + }); + it('fetches enterprise enrollments', () => { const url = 'http://localhost:18000/enterprise_learner_portal/api/v1/enterprise_course_enrollments/?enterprise_id=test-enterprise-id&is_active=true'; fetchEnterpriseCourseEnrollments('test-enterprise-id'); expect(axios.get).toBeCalledWith(url); }); + + it('acknowledges specified content assignments', () => { + const url = 'http://enterprise-access.url/api/v1/assignment-configurations/test-assignment-configuration-id/acknowledge-assignments/'; + const assignmentIds = ['test-assignment-id-1', 'test-assignment-id-2']; + const assignmentConfigurationId = 'test-assignment-configuration-id'; + const expectedPayload = { + assignment_uuids: assignmentIds, + }; + acknowledgeContentAssignments({ + assignmentConfigurationId, + assignmentIds, + }); + expect(axios.post).toBeCalledWith(url, expectedPayload); + }); }); diff --git a/src/components/dashboard/main-content/course-enrollments/data/tests/utils.test.js b/src/components/dashboard/main-content/course-enrollments/data/tests/utils.test.js index a8f584da68..f221a084f2 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/tests/utils.test.js +++ b/src/components/dashboard/main-content/course-enrollments/data/tests/utils.test.js @@ -1,5 +1,4 @@ import { camelCaseObject } from '@edx/frontend-platform'; -import dayjs from 'dayjs'; import MockDate from 'mockdate'; import { COURSE_STATUSES } from '../constants'; @@ -10,7 +9,6 @@ import { sortAssignmentsByAssignmentStatus, } from '../utils'; import { createRawCourseEnrollment } from '../../tests/enrollment-testutils'; -import { isAssignmentExpired } from '../../../../data/utils'; describe('transformCourseEnrollment', () => { it('should transform a course enrollment', () => { @@ -123,78 +121,6 @@ describe('groupCourseEnrollmentsByStatus', () => { }); }); -describe('isAssignmentExpired', () => { - const currentDate = '2023-04-20'; - const futureDate = '2024-04-20'; - const pastDate = '2022-04-20'; - - beforeAll(() => { - MockDate.set(currentDate); - }); - - afterAll(() => { - MockDate.reset(); - }); - - it('handles null/undefined assignment', () => { - expect(isAssignmentExpired(null)).toEqual({ - isExpired: false, - enrollByDeadline: undefined, - }); - expect(isAssignmentExpired(undefined)).toEqual({ - isExpired: false, - enrollByDeadline: undefined, - }); - }); - - it.each([ - { - created: pastDate, - enrollByDate: pastDate, - subsidyExpirationDate: futureDate, - isExpired: true, - }, - { - created: currentDate, - enrollByDate: pastDate, - subsidyExpirationDate: futureDate, - isExpired: true, - }, - { - created: currentDate, - enrollByDate: futureDate, - subsidyExpirationDate: pastDate, - isExpired: true, - }, - { - created: currentDate, - enrollByDate: futureDate, - subsidyExpirationDate: futureDate, - isExpired: false, - }, - ])('checks whether assignment is expired (%s)', ({ - created, - enrollByDate, - subsidyExpirationDate, - isExpired, - }) => { - const allocatedAssignment = { - created, - contentMetadata: { enrollByDate }, - subsidyExpirationDate, - }; - const earliestAssignmentExpiryDate = [ - dayjs(created).add(90, 'd'), - dayjs(enrollByDate), - dayjs(subsidyExpirationDate), - ].sort((a, b) => (dayjs(a).isAfter(b) ? 1 : -1))[0]; - expect(isAssignmentExpired(allocatedAssignment)).toEqual({ - isExpired, - enrollByDeadline: earliestAssignmentExpiryDate.toDate(), - }); - }); -}); - describe('sortAssignmentsByAssignmentStatus', () => { beforeAll(() => { MockDate.set('2023-04-20'); @@ -233,11 +159,4 @@ describe('sortAssignmentsByAssignmentStatus', () => { expect(sortedAssignments).toEqual(expectedSortedAssignments); }); - - it('returns empty array for null assignments', () => { - const assignments = null; - const expectedAssignments = []; - const sortedAssignments = sortAssignmentsByAssignmentStatus(assignments); - expect(sortedAssignments).toEqual(expectedAssignments); - }); }); 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 1bd0dbe42d..246eac936e 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/utils.js +++ b/src/components/dashboard/main-content/course-enrollments/data/utils.js @@ -1,6 +1,5 @@ import dayjs from 'dayjs'; import { COURSE_STATUSES } from './constants'; -import { isAssignmentExpired } from '../../../data/utils'; import { ASSIGNMENT_TYPES } from '../../../../enterprise-user-subsidy/enterprise-offers/data/constants'; /** @@ -92,30 +91,34 @@ export const transformSubsidyRequest = ({ * @returns {array} - Returns the sorted array of assignments. */ export const sortAssignmentsByAssignmentStatus = (assignments) => { - if (!assignments) { - return []; - } const assignmentsCopy = [...assignments]; const sortedAssignments = assignmentsCopy.sort((a, b) => { - const isAssignmentACanceledOrExpired = a.state === 'cancelled' || isAssignmentExpired(a).isExpired ? 1 : 0; - const isAssignmentBCanceledOrExpired = b.state === 'cancelled' || isAssignmentExpired(b).isExpired ? 1 : 0; + const isAssignmentACanceledOrExpired = ['cancelled', 'expired'].includes(a.state) ? 1 : 0; + const isAssignmentBCanceledOrExpired = ['cancelled', 'expired'].includes(b.state) ? 1 : 0; return isAssignmentACanceledOrExpired - isAssignmentBCanceledOrExpired; }); return sortedAssignments; }; -export const getTransformedAllocatedAssignments = (assignments, slug) => { - if (!assignments) { - return assignments; - } - const updatedAssignments = assignments?.map((item) => { +/** + * Transforms a learner assignment into a shape consistent with course + * enrollments, including additional fields specific to learner content + * assignments (e.g., isCanceledAssignment, isExpiredAssignment, + * assignmentConfiguration). Used for the display of CourseCard component(s) + * while acknowledging canceled/expired assignments via the + * `useAcknowledgeContentAssignments` hook. + * + * @param {Array} assignments - Array of assignments to be transformed. + * @param {String} enterpriseSlug - Slug of the enterprise. + * @returns {Array} - Returns the transformed array of assignments. + */ +export const getTransformedAllocatedAssignments = (assignments, enterpriseSlug) => { + const updatedAssignments = assignments.map((item) => { const isCanceledAssignment = item.state === ASSIGNMENT_TYPES.CANCELED; - const { - isExpired: isExpiredAssignment, - enrollByDeadline: assignmentEnrollByDeadline, - } = isAssignmentExpired(item); + const isExpiredAssignment = item.state === ASSIGNMENT_TYPES.EXPIRED; + const { date: assignmentEnrollByDeadline } = item.earliestPossibleExpiration; return { - linkToCourse: `/${slug}/course/${item.contentKey}`, + linkToCourse: `/${enterpriseSlug}/course/${item.contentKey}`, // Note: we are using `courseRunId` instead of `contentKey` or `courseKey` because the `CourseSection` // and `BaseCourseCard` components expect `courseRunId` to be used as the content identifier. Consider // refactoring to rename `courseRunId` to `contentKey` in the future given learner content assignments @@ -132,6 +135,8 @@ export const getTransformedAllocatedAssignments = (assignments, slug) => { enrollBy: assignmentEnrollByDeadline, isCanceledAssignment, isExpiredAssignment, + assignmentConfiguration: item.assignmentConfiguration, + uuid: item.uuid, }; }); return updatedAssignments; 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 759f75d31e..0682918ab4 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 @@ -127,6 +127,8 @@ jest.mock('../data/utils', () => ({ })); describe('Course enrollments', () => { + const mockAcknowledgeAssignments = jest.fn(); + beforeEach(() => { updateCourseCompleteStatusRequest.mockImplementation(() => ({ data: {} })); sortAssignmentsByAssignmentStatus.mockReturnValue([assignmentData]); @@ -159,13 +161,12 @@ describe('Course enrollments', () => { startDate: dayjs().subtract(1, 'day').toISOString(), mode: 'verified', }; - const mockCloseCancelAlert = jest.fn(); + hooks.useContentAssignments.mockReturnValue({ assignments: [mockAssignment], showCanceledAssignmentsAlert: true, showExpiredAssignmentsAlert: false, - handleOnCloseCancelAlert: mockCloseCancelAlert, - handleOnCloseExpiredAlert: jest.fn(), + handleAcknowledgeAssignments: mockAcknowledgeAssignments, }); renderWithRouter(); // Verify canceled assignment card is visible initially @@ -176,7 +177,8 @@ describe('Course enrollments', () => { // Handles dismiss behavior const dismissButton = screen.getByRole('button', { name: 'Dismiss' }); userEvent.click(dismissButton); - expect(mockCloseCancelAlert).toHaveBeenCalledTimes(1); + expect(mockAcknowledgeAssignments).toHaveBeenCalledTimes(1); + expect(mockAcknowledgeAssignments).toHaveBeenCalledWith({ assignmentState: ASSIGNMENT_TYPES.CANCELED }); }); it('renders alert for expired assignments and renders expired assignment cards with dismiss behavior', async () => { @@ -194,13 +196,11 @@ describe('Course enrollments', () => { startDate: dayjs().subtract(30, 'day').toISOString(), mode: 'verified', }; - const mockCloseExpiredAlert = jest.fn(); hooks.useContentAssignments.mockReturnValue({ assignments: [mockAssignment], showCanceledAssignmentsAlert: false, showExpiredAssignmentsAlert: true, - handleOnCloseCancelAlert: jest.fn(), - handleOnCloseExpiredAlert: mockCloseExpiredAlert, + handleAcknowledgeAssignments: mockAcknowledgeAssignments, }); renderWithRouter(); // Verify canceled assignment card is visible initially @@ -211,7 +211,8 @@ describe('Course enrollments', () => { // Handles dismiss behavior const dismissButton = screen.getByRole('button', { name: 'Dismiss' }); userEvent.click(dismissButton); - expect(mockCloseExpiredAlert).toHaveBeenCalledTimes(1); + expect(mockAcknowledgeAssignments).toHaveBeenCalledTimes(1); + expect(mockAcknowledgeAssignments).toHaveBeenCalledWith({ assignmentState: ASSIGNMENT_TYPES.EXPIRED }); }); it('generates course status update on move to in progress action', async () => { diff --git a/src/components/enterprise-subsidy-requests/data/service.js b/src/components/enterprise-subsidy-requests/data/service.js index 91e22141e0..47aa1563d7 100644 --- a/src/components/enterprise-subsidy-requests/data/service.js +++ b/src/components/enterprise-subsidy-requests/data/service.js @@ -3,8 +3,7 @@ import { getConfig } from '@edx/frontend-platform/config'; import { SUBSIDY_REQUEST_STATE } from '../constants'; export function fetchSubsidyRequestConfiguration(enterpriseUUID) { - const config = getConfig(); - const url = `${config.ENTERPRISE_ACCESS_BASE_URL}/api/v1/customer-configurations/${enterpriseUUID}/`; + const url = `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/customer-configurations/${enterpriseUUID}/`; return getAuthenticatedHttpClient().get(url); } diff --git a/src/components/enterprise-subsidy-requests/data/tests/service.test.js b/src/components/enterprise-subsidy-requests/data/tests/service.test.js index 4076cf16ce..2f59f7a4cb 100644 --- a/src/components/enterprise-subsidy-requests/data/tests/service.test.js +++ b/src/components/enterprise-subsidy-requests/data/tests/service.test.js @@ -1,6 +1,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { mergeConfig } from '@edx/frontend-platform/config'; import { fetchSubsidyRequestConfiguration, @@ -20,6 +21,12 @@ const mockEnterpriseUUID = 'test-enterprise-id'; const mockEmail = 'edx@example.com'; describe('fetchSubsidyRequestConfiguration', () => { + beforeEach(() => { + mergeConfig({ + ENTERPRISE_ACCESS_BASE_URL: enterpriseAccessBaseUrl, + }); + }); + it('fetches subsidy request configuration for the given enterprise', () => { fetchSubsidyRequestConfiguration(mockEnterpriseUUID); expect(axios.get).toBeCalledWith(`${enterpriseAccessBaseUrl}/api/v1/customer-configurations/${mockEnterpriseUUID}/`); diff --git a/src/components/enterprise-user-subsidy/data/constants.js b/src/components/enterprise-user-subsidy/data/constants.js index 06535d7127..0f4f905d82 100644 --- a/src/components/enterprise-user-subsidy/data/constants.js +++ b/src/components/enterprise-user-subsidy/data/constants.js @@ -46,6 +46,8 @@ export const emptyRedeemableLearnerCreditPolicies = { hasAllocatedAssignments: false, canceledAssignments: [], hasCanceledAssignments: false, + expiredAssignments: [], + hasExpiredAssignments: false, acceptedAssignments: [], hasAcceptedAssignments: false, erroredAssignments: [], diff --git a/src/components/enterprise-user-subsidy/data/hooks/hooks.js b/src/components/enterprise-user-subsidy/data/hooks/hooks.js index 9ad83f7cfe..01febd542a 100644 --- a/src/components/enterprise-user-subsidy/data/hooks/hooks.js +++ b/src/components/enterprise-user-subsidy/data/hooks/hooks.js @@ -19,8 +19,7 @@ import { } from '../service'; import { features } from '../../../../config'; import { fetchCouponsOverview } from '../../coupons/data/service'; -import { transformRedeemablePoliciesData } from '../utils'; -import { getAssignmentsByState } from '../../../dashboard/data/utils'; +import { transformRedeemablePoliciesData, getAssignmentsByState } from '../utils'; /** * Attempts to fetch any existing licenses associated with the authenticated user and the diff --git a/src/components/enterprise-user-subsidy/data/hooks/hooks.test.jsx b/src/components/enterprise-user-subsidy/data/hooks/hooks.test.jsx index 8ad9e537fb..6afb73a9aa 100644 --- a/src/components/enterprise-user-subsidy/data/hooks/hooks.test.jsx +++ b/src/components/enterprise-user-subsidy/data/hooks/hooks.test.jsx @@ -391,21 +391,39 @@ const Wrapper = ({ children }) => ( describe('useRedeemableLearnerCreditPolicies', () => { it('fetches and returns redeemable learner credit policies', async () => { - const mockAllocatedAssignment = { + const mockBaseAssignment = { uuid: 'test-assignment-uuid', + subsidyExpirationDate: mockLearnerCreditPolicy.subsidy_expiration_date, + }; + const mockAllocatedAssignment = { + ...mockBaseAssignment, state: ASSIGNMENT_TYPES.ALLOCATED, }; - const mockCanceledssignment = { - uuid: 'test-assignment-uuid', + const mockCanceledAssignment = { + ...mockBaseAssignment, state: ASSIGNMENT_TYPES.CANCELED, }; const mockAcceptedAssignment = { - uuid: 'test-assignment-uuid', + ...mockBaseAssignment, state: ASSIGNMENT_TYPES.ACCEPTED, }; + const mockExpiredAssignment = { + ...mockBaseAssignment, + state: ASSIGNMENT_TYPES.EXPIRED, + }; + const mockErroredAssignment = { + ...mockBaseAssignment, + state: ASSIGNMENT_TYPES.ERRORED, + }; const mockAssignablePolicy = { ...mockLearnerCreditPolicy, - learner_content_assignments: [mockAllocatedAssignment, mockCanceledssignment, mockAcceptedAssignment], + learner_content_assignments: [ + mockAllocatedAssignment, + mockCanceledAssignment, + mockAcceptedAssignment, + mockExpiredAssignment, + mockErroredAssignment, + ], }; fetchRedeemableLearnerCreditPolicies.mockResolvedValueOnce({ data: [mockLearnerCreditPolicy, mockAssignablePolicy], @@ -417,49 +435,43 @@ describe('useRedeemableLearnerCreditPolicies', () => { await waitForNextUpdate(); expect(fetchRedeemableLearnerCreditPolicies).toHaveBeenCalledWith(TEST_ENTERPRISE_UUID, TEST_USER_ID); - const mockAllocatedAssignmentWithPlanExpiration = { - ...mockAllocatedAssignment, - subsidyExpirationDate: mockLearnerCreditPolicy.subsidy_expiration_date, - }; - const mockCanceledssignmentWithPlanExpiration = { - ...mockCanceledssignment, - subsidyExpirationDate: mockLearnerCreditPolicy.subsidy_expiration_date, - }; - const mockAcceptedAssignmentWithPlanExpiration = { - ...mockAcceptedAssignment, - subsidyExpirationDate: mockLearnerCreditPolicy.subsidy_expiration_date, - }; - expect(result.current.data).toEqual({ redeemablePolicies: [ camelCaseObject(mockLearnerCreditPolicy), camelCaseObject({ ...mockAssignablePolicy, learnerContentAssignments: [ - mockAllocatedAssignmentWithPlanExpiration, - mockCanceledssignmentWithPlanExpiration, - mockAcceptedAssignmentWithPlanExpiration, + mockAllocatedAssignment, + mockCanceledAssignment, + mockAcceptedAssignment, + mockExpiredAssignment, + mockErroredAssignment, ], }), ], learnerContentAssignments: { assignments: [ - mockAllocatedAssignmentWithPlanExpiration, - mockCanceledssignmentWithPlanExpiration, - mockAcceptedAssignmentWithPlanExpiration, + mockAllocatedAssignment, + mockCanceledAssignment, + mockAcceptedAssignment, + mockExpiredAssignment, + mockErroredAssignment, ], hasAssignments: true, - allocatedAssignments: [mockAllocatedAssignmentWithPlanExpiration], + allocatedAssignments: [mockAllocatedAssignment], hasAllocatedAssignments: true, - canceledAssignments: [mockCanceledssignmentWithPlanExpiration], + canceledAssignments: [mockCanceledAssignment], hasCanceledAssignments: true, - acceptedAssignments: [mockAcceptedAssignmentWithPlanExpiration], + expiredAssignments: [mockExpiredAssignment], + hasExpiredAssignments: true, + acceptedAssignments: [mockAcceptedAssignment], hasAcceptedAssignments: true, - erroredAssignments: [], - hasErroredAssignments: false, + erroredAssignments: [mockErroredAssignment], + hasErroredAssignments: true, assignmentsForDisplay: [ - mockAllocatedAssignmentWithPlanExpiration, - mockCanceledssignmentWithPlanExpiration, + mockAllocatedAssignment, + mockCanceledAssignment, + mockExpiredAssignment, ], hasAssignmentsForDisplay: true, }, diff --git a/src/components/enterprise-user-subsidy/data/utils.js b/src/components/enterprise-user-subsidy/data/utils.js index c5664e296f..58e6bfb0a2 100644 --- a/src/components/enterprise-user-subsidy/data/utils.js +++ b/src/components/enterprise-user-subsidy/data/utils.js @@ -1,4 +1,6 @@ -import { POLICY_TYPES } from '../enterprise-offers/data/constants'; +import { logError } from '@edx/frontend-platform/logging'; + +import { ASSIGNMENT_TYPES, POLICY_TYPES } from '../enterprise-offers/data/constants'; import { LICENSE_STATUS } from './constants'; /** @@ -69,3 +71,81 @@ export const determineLearnerHasContentAssignmentsOnly = ({ && !hasAutoAppliedLearnerCreditPolicies ); }; + +/** + * Takes a flattened array of assignments and returns an object containing + * lists of assignments for each assignment state. + * + * @param {Array} assignments - List of content assignments. + * @returns {{ +* assignments: Array, +* hasAssignments: Boolean, +* allocatedAssignments: Array, +* hasAllocatedAssignments: Boolean, +* canceledAssignments: Array, +* hasCanceledAssignments: Boolean, +* acceptedAssignments: Array, +* hasAcceptedAssignments: Boolean, +* }} +*/ +export function getAssignmentsByState(assignments = []) { + const allocatedAssignments = []; + const acceptedAssignments = []; + const canceledAssignments = []; + const expiredAssignments = []; + const erroredAssignments = []; + const assignmentsForDisplay = []; + + assignments.forEach((assignment) => { + switch (assignment.state) { + case ASSIGNMENT_TYPES.ALLOCATED: + allocatedAssignments.push(assignment); + break; + case ASSIGNMENT_TYPES.ACCEPTED: + acceptedAssignments.push(assignment); + break; + case ASSIGNMENT_TYPES.CANCELED: + canceledAssignments.push(assignment); + break; + case ASSIGNMENT_TYPES.EXPIRED: + expiredAssignments.push(assignment); + break; + case ASSIGNMENT_TYPES.ERRORED: + erroredAssignments.push(assignment); + break; + default: + logError(`[getAssignmentsByState] Unsupported state ${assignment.state} for assignment ${assignment.uuid}`); + break; + } + }); + + const hasAssignments = assignments.length > 0; + const hasAllocatedAssignments = allocatedAssignments.length > 0; + const hasAcceptedAssignments = acceptedAssignments.length > 0; + const hasCanceledAssignments = canceledAssignments.length > 0; + const hasExpiredAssignments = expiredAssignments.length > 0; + const hasErroredAssignments = erroredAssignments.length > 0; + + // Concatenate all assignments for display (includes allocated and canceled assignments) + assignmentsForDisplay.push(...allocatedAssignments); + assignmentsForDisplay.push(...canceledAssignments); + assignmentsForDisplay.push(...expiredAssignments); + const hasAssignmentsForDisplay = assignmentsForDisplay.length > 0; + + return { + assignments, + hasAssignments, + allocatedAssignments, + hasAllocatedAssignments, + acceptedAssignments, + hasAcceptedAssignments, + canceledAssignments, + hasCanceledAssignments, + expiredAssignments, + hasExpiredAssignments, + erroredAssignments, + hasErroredAssignments, + assignmentsForDisplay, + hasAssignmentsForDisplay, + }; +} 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 d0d10d18e4..c0df59bb38 100644 --- a/src/components/enterprise-user-subsidy/enterprise-offers/data/constants.js +++ b/src/components/enterprise-user-subsidy/enterprise-offers/data/constants.js @@ -34,14 +34,10 @@ export const ASSIGNMENT_TYPES = { ACCEPTED: 'accepted', ALLOCATED: 'allocated', CANCELED: 'cancelled', + EXPIRED: 'expired', ERRORED: 'errored', }; -export const ASSIGNMENT_ACTION_TYPES = { - CANCELED: 'cancelled', - AUTOMATIC_CANCELATION: 'automatic_cancellation', -}; - export const POLICY_TYPES = { ASSIGNED_CREDIT: 'AssignedLearnerCreditAccessPolicy', PER_LEARNER_CREDIT: 'PerLearnerSpendCreditAccessPolicy', diff --git a/src/setupTest.js b/src/setupTest.js index f983334bfd..8f70cf1048 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -16,6 +16,7 @@ process.env.MARKETING_SITE_BASE_URL = 'http://marketing.url'; process.env.LEARNER_SUPPORT_SPEND_ENROLLMENT_LIMITS_URL = 'http://limits.url'; process.env.LOGOUT_URL = 'http://localhost:18000/logout'; process.env.BASE_URL = 'http://localhost:8734'; +process.env.ENTERPRISE_ACCESS_BASE_URL = 'http://enterprise-access.url'; // testing utility to mock window width, etc. global.window.matchMedia = matchMediaMock.create();