diff --git a/src/components/dashboard/DashboardPage.jsx b/src/components/dashboard/DashboardPage.jsx index b9c1a20afb..57c39cb5be 100644 --- a/src/components/dashboard/DashboardPage.jsx +++ b/src/components/dashboard/DashboardPage.jsx @@ -1,13 +1,7 @@ -import React, { - useContext, useEffect, useMemo, -} from 'react'; +import React, { useContext, useEffect, useMemo } from 'react'; import { Helmet } from 'react-helmet'; import { useHistory, useLocation } from 'react-router-dom'; -import { - Container, - Tabs, - Tab, -} from '@edx/paragon'; +import { Container, Tab, Tabs } from '@edx/paragon'; import { AppContext } from '@edx/frontend-platform/react'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { ProgramListingPage } from '../program-progress'; @@ -18,17 +12,20 @@ import { useLearnerProgramsListData } from '../program-progress/data/hooks'; import { useInProgressPathwaysData } from '../pathway-progress/data/hooks'; import CoursesTabComponent from './main-content/CoursesTabComponent'; import { MyCareerTab } from '../my-career'; -import EnterpriseLearnerFirstVisitRedirect from '../enterprise-redirects/EnterpriseLearnerFirstVisitRedirect'; import { UserSubsidyContext } from '../enterprise-user-subsidy'; import { IntegrationWarningModal } from '../integration-warning-modal'; import SubscriptionExpirationModal from './SubscriptionExpirationModal'; +import EnterpriseLearnerFirstVisitRedirect from '../enterprise-redirects/EnterpriseLearnerFirstVisitRedirect'; const DashboardPage = () => { const { state } = useLocation(); const history = useHistory(); const { enterpriseConfig, authenticatedUser } = useContext(AppContext); const { username } = authenticatedUser; - const { subscriptionPlan, showExpirationNotifications } = useContext(UserSubsidyContext); + const { + subscriptionPlan, + showExpirationNotifications, + } = useContext(UserSubsidyContext); // TODO: Create a context provider containing these 2 data fetch hooks to future proof when we need to use this data const [learnerProgramsListData, programsFetchError] = useLearnerProgramsListData(enterpriseConfig.uuid); const [pathwayProgressData, pathwayFetchError] = useInProgressPathwaysData(enterpriseConfig.uuid); diff --git a/src/components/dashboard/data/utils.js b/src/components/dashboard/data/utils.js new file mode 100644 index 0000000000..0a90ed832d --- /dev/null +++ b/src/components/dashboard/data/utils.js @@ -0,0 +1,18 @@ +import { ASSIGNMENT_TYPES } from '../../enterprise-user-subsidy/enterprise-offers/data/constants'; + +/** + * Takes the flattened array from redeemableLearnerCreditPolicies and returns the options of + * the array of activeAssignments, or hasActiveAssignments which returns a boolean value + * @param assignments - flatMap'ed object from redeemableLearnerCreditPolicies for learnerContentAssignments + * @returns {{hasActiveAssignments: boolean, activeAssignments: Array}} + */ +export default function getActiveAssignments(assignments = []) { + const activeAssignments = assignments.filter((assignment) => [ + ASSIGNMENT_TYPES.CANCELLED, ASSIGNMENT_TYPES.ALLOCATED, + ].includes(assignment.state)); + const hasActiveAssignments = activeAssignments.length > 0; + return { + activeAssignments, + hasActiveAssignments, + }; +} diff --git a/src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx b/src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx index f27a1c6180..8a22eb1313 100644 --- a/src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx +++ b/src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx @@ -10,11 +10,15 @@ import CourseEnrollmentsAlert from './CourseEnrollmentsAlert'; import CourseAssignmentAlert from './CourseAssignmentAlert'; import { CourseEnrollmentsContext } from './CourseEnrollmentsContextProvider'; import { - getTransformedAllocatedAssignments, sortedEnrollmentsByEnrollmentDate, sortAssignmentsByAssignmentStatus, + getTransformedAllocatedAssignments, isAssignmentExpired, + sortAssignmentsByAssignmentStatus, + sortedEnrollmentsByEnrollmentDate, } from './data/utils'; import { UserSubsidyContext } from '../../../enterprise-user-subsidy'; import { features } from '../../../../config'; +import getActiveAssignments from '../../data/utils'; +import { ASSIGNMENT_TYPES } from '../../../enterprise-user-subsidy/enterprise-offers/data/constants'; export const COURSE_SECTION_TITLES = { current: 'My courses', @@ -22,11 +26,6 @@ export const COURSE_SECTION_TITLES = { savedForLater: 'Saved for later', assigned: 'Assigned Courses', }; -export const ASSIGNMENT_TYPES = { - accepted: 'accepted', - allocated: 'allocated', - cancelled: 'cancelled', -}; const CourseEnrollments = ({ children }) => { const { @@ -51,28 +50,27 @@ const CourseEnrollments = ({ children }) => { const [showExpiredAssignmentsAlert, setShowExpiredAssignmentsAlert] = useState(false); useEffect(() => { + // TODO: Refactor to DRY up code for redeemableLearnerCreditPolicies const data = redeemableLearnerCreditPolicies?.flatMap(item => item?.learnerContentAssignments || []); const assignmentsData = sortAssignmentsByAssignmentStatus(data); setAssignments(assignmentsData); const hasCancelledAssignments = assignmentsData?.some( - assignment => assignment.state === ASSIGNMENT_TYPES.cancelled, + assignment => assignment.state === ASSIGNMENT_TYPES.CANCELLED, ); const hasExpiredAssignments = assignmentsData?.some(assignment => isAssignmentExpired(assignment)); setShowCancelledAssignmentsAlert(hasCancelledAssignments); setShowExpiredAssignmentsAlert(hasExpiredAssignments); }, [redeemableLearnerCreditPolicies]); - - const filteredAssignments = assignments?.filter((assignment) => assignment?.state === ASSIGNMENT_TYPES.allocated - || assignment?.state === ASSIGNMENT_TYPES.cancelled); - const assignedCourses = getTransformedAllocatedAssignments(filteredAssignments, slug); + const { activeAssigments, hasActiveAssignments } = getActiveAssignments(assignments); + const assignedCourses = getTransformedAllocatedAssignments(activeAssigments, slug); const currentCourseEnrollments = useMemo( () => { Object.keys(courseEnrollmentsByStatus).forEach((status) => { courseEnrollmentsByStatus[status] = courseEnrollmentsByStatus[status].map((course) => { - const isAssigned = assignments?.some(assignment => (assignment?.state === ASSIGNMENT_TYPES.accepted + const isAssigned = assignments?.some(assignment => (assignment?.state === ASSIGNMENT_TYPES.ACCEPTED && course.courseRunId.includes(assignment?.contentKey))); if (isAssigned) { return { ...course, isCourseAssigned: true }; @@ -113,8 +111,6 @@ const CourseEnrollments = ({ children }) => { } const hasCourseEnrollments = Object.values(courseEnrollmentsByStatus).flat().length > 0; - const hasCourseAssignments = filteredAssignments?.length > 0; - return ( <> {showCancelledAssignmentsAlert && ( @@ -138,7 +134,7 @@ const CourseEnrollments = ({ children }) => { This allows the parent component to customize what gets displayed if the user does not have any course enrollments. */} - {(!hasCourseEnrollments && !hasCourseAssignments) && children} + {(!hasCourseEnrollments && !hasActiveAssignments) && children} <> {features.FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT && ( ({ + ...jest.requireActual('@edx/frontend-enterprise-utils'), + sendEnterpriseTrackEvent: jest.fn(), +})); + jest.mock('../../../config', () => ({ features: { FEATURE_ENABLE_PATHWAY_PROGRESS: jest.fn(), @@ -52,6 +51,10 @@ jest.mock('../main-content/course-enrollments/data/utils', () => ({ sortAssignmentsByAssignmentStatus: jest.fn(), })); +jest.mock('../../enterprise-redirects/EnterpriseLearnerFirstVisitRedirect', () => jest.fn( + () => (
enterprise-learner-first-visit-redirect
), +)); + const defaultAppState = { enterpriseConfig: { name: 'BearsRUs', @@ -68,6 +71,16 @@ const defaultAppState = { const defaultUserSubsidyState = { couponCodes: defaultCouponCodesState, enterpriseOffers: [], + redeemableLearnerCreditPolicies: [{ + learnerContentAssignments: { + state: 'allocated', + }, + }, + { + learnerContentAssignments: { + state: 'cancelled', + }, + }], }; const defaultCourseState = { @@ -176,12 +189,13 @@ describe('', () => { }); beforeEach(() => { + jest.clearAllMocks(); sortAssignmentsByAssignmentStatus.mockReturnValue([]); }); it('renders user first name if available', () => { renderWithRouter(); - expect(screen.getByText('Welcome, John!')); + expect(screen.getByText('Welcome, John!')).toBeInTheDocument(); }); it('does not render user first name if not available', () => { @@ -193,7 +207,7 @@ describe('', () => { }, }; renderWithRouter(); - expect(screen.getByText('Welcome!')); + expect(screen.getByText('Welcome!')).toBeInTheDocument(); }); it('renders license activation alert on activation success', () => { @@ -201,7 +215,7 @@ describe('', () => { , { route: '/?activationSuccess=true' }, ); - expect(screen.getByText(LICENSE_ACTIVATION_MESSAGE)); + expect(screen.getByText(LICENSE_ACTIVATION_MESSAGE)).toBeInTheDocument(); }); it('does not render license activation alert without activation success', () => { @@ -218,7 +232,7 @@ describe('', () => { renderWithRouter( , ); - expect(screen.getByTestId('sidebar')); + expect(screen.getByTestId('sidebar')).toBeInTheDocument(); }); it('renders subsidies summary on a small screen', () => { @@ -233,14 +247,14 @@ describe('', () => { }} />, ); - expect(screen.getByTestId('subsidies-summary')); + expect(screen.getByTestId('subsidies-summary')).toBeInTheDocument(); }); it('renders "Find a course" when search is enabled for the customer', () => { renderWithRouter( , ); - expect(screen.getByText('Find a course')); + expect(screen.getByText('Find a course')).toBeInTheDocument(); }); it('renders Pathways when feature is enabled', () => { @@ -258,7 +272,7 @@ describe('', () => { renderWithRouter( , ); - expect(screen.getByText('Pathways')); + expect(screen.getByText('Pathways')).toBeInTheDocument(); }); it('renders My Career when feature is enabled', () => { @@ -266,7 +280,7 @@ describe('', () => { renderWithRouter( , ); - expect(screen.getByText('My Career')); + expect(screen.getByText('My Career')).toBeInTheDocument(); }); it('does not render "Find a course" when search is disabled for the customer', () => { @@ -331,6 +345,24 @@ describe('', () => { expect(programsTab).toHaveAttribute('aria-selected', 'false'); }); + it('should send track event when "my-career" tab selected', () => { + renderWithRouter(); + + const myCareerTab = screen.getByText('My Career'); + userEvent.click(myCareerTab); + + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('should render redirect component if no cookie and no courseAssignments exist', () => { + const noActiveCourseAssignmentUserSubsidyState = { + ...defaultUserSubsidyState, + redeemableLearnerCreditPolicies: [], + }; + renderWithRouter(); + expect(screen.queryByText('enterprise-learner-first-visit-redirect')).toBeTruthy(); + }); + describe('SubscriptionExpirationModal', () => { it('should not render when > 60 days of access remain', () => { renderWithRouter( diff --git a/src/components/enterprise-redirects/EnterpriseLearnerFirstVisitRedirect.jsx b/src/components/enterprise-redirects/EnterpriseLearnerFirstVisitRedirect.jsx index 7e7cd165b7..d8dfc96cb1 100644 --- a/src/components/enterprise-redirects/EnterpriseLearnerFirstVisitRedirect.jsx +++ b/src/components/enterprise-redirects/EnterpriseLearnerFirstVisitRedirect.jsx @@ -1,24 +1,39 @@ -import React, { useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import { Redirect, useParams } from 'react-router-dom'; import Cookies from 'universal-cookie'; +import { UserSubsidyContext } from '../enterprise-user-subsidy'; +import getActiveAssignments from '../dashboard/data/utils'; + +export const isFirstDashboardPageVisit = () => { + const cookies = new Cookies(); + + const hasUserVisitedDashboard = cookies.get('has-user-visited-learner-dashboard'); + return !hasUserVisitedDashboard; +}; const EnterpriseLearnerFirstVisitRedirect = () => { const { enterpriseSlug } = useParams(); + const { + redeemableLearnerCreditPolicies, + } = useContext(UserSubsidyContext); - const cookies = new Cookies(); - - const isFirstVisit = () => { - const hasUserVisitedDashboard = cookies.get('has-user-visited-learner-dashboard'); - return !hasUserVisitedDashboard; + // TODO: Refactor to DRY up code for redeemableLearnerCreditPolicies + const hasActiveContentAssignments = (learnerCreditPolicies) => { + const learnerContentAssignmentsArray = learnerCreditPolicies?.flatMap( + item => item?.learnerContentAssignments || [], + ); + // filters out course assignments that are not considered active and returns a boolean value + return getActiveAssignments(learnerContentAssignmentsArray).hasActiveAssignments; }; - useEffect(() => { - if (isFirstVisit()) { + const cookies = new Cookies(); + + if (isFirstDashboardPageVisit()) { cookies.set('has-user-visited-learner-dashboard', true, { path: '/' }); } - }); + }, []); - if (isFirstVisit()) { + if (!hasActiveContentAssignments(redeemableLearnerCreditPolicies) && isFirstDashboardPageVisit()) { return ; } diff --git a/src/components/enterprise-redirects/tests/EnterpriseLearnerFirstVisitRedirect.test.jsx b/src/components/enterprise-redirects/tests/EnterpriseLearnerFirstVisitRedirect.test.jsx index b00dde8596..05e6bc9809 100644 --- a/src/components/enterprise-redirects/tests/EnterpriseLearnerFirstVisitRedirect.test.jsx +++ b/src/components/enterprise-redirects/tests/EnterpriseLearnerFirstVisitRedirect.test.jsx @@ -1,9 +1,9 @@ import React from 'react'; import '@testing-library/jest-dom/extend-expect'; import Cookies from 'universal-cookie'; - import { renderWithRouter } from '../../../utils/tests'; import EnterpriseLearnerFirstVisitRedirect from '../EnterpriseLearnerFirstVisitRedirect'; +import { UserSubsidyContext } from '../../enterprise-user-subsidy'; const COOKIE_NAME = 'has-user-visited-learner-dashboard'; const TEST_ENTERPRISE = { @@ -19,6 +19,27 @@ jest.mock('react-router-dom', () => ({ }), })); +const defaultUserSubsidyState = { + redeemableLearnerCreditPolicies: [{ + learnerContentAssignments: { + state: 'allocated', + }, + }, + { + learnerContentAssignments: { + state: 'cancelled', + }, + }], +}; + +const EnterpriseLearnerFirstVisitRedirectWrapper = ({ + initialUserSubsidyState = defaultUserSubsidyState, +}) => ( + + + +); + describe('', () => { beforeEach(() => { const cookies = new Cookies(); @@ -26,7 +47,22 @@ describe('', () => { }); test('redirects to search if user is visiting for the first time.', async () => { - const { history } = renderWithRouter(, { route: `/${TEST_ENTERPRISE.slug}` }); + const noActiveCourseAssignmentUserSubsidyState = { + ...defaultUserSubsidyState, + redeemableLearnerCreditPolicies: [], + }; + + const { history } = renderWithRouter(, { route: `/${TEST_ENTERPRISE.slug}` }); + expect(history.location.pathname).toEqual(`/${TEST_ENTERPRISE.slug}/search`); + }); + + test('redirects to search if the course assigned is not active.', async () => { + const noActiveCourseAssignmentUserSubsidyState = { + ...defaultUserSubsidyState, + redeemableLearnerCreditPolicies: [], + }; + + const { history } = renderWithRouter(, { route: `/${TEST_ENTERPRISE.slug}` }); expect(history.location.pathname).toEqual(`/${TEST_ENTERPRISE.slug}/search`); }); @@ -35,7 +71,7 @@ describe('', () => { const cookies = new Cookies(); cookies.set(COOKIE_NAME, true); - const { history } = renderWithRouter(, { route: `/${TEST_ENTERPRISE.slug}` }); + const { history } = renderWithRouter(, { route: `/${TEST_ENTERPRISE.slug}` }); expect(history.location.pathname).toEqual(`/${TEST_ENTERPRISE.slug}`); }); @@ -44,7 +80,7 @@ describe('', () => { const cookies = new Cookies(); cookies.set(COOKIE_NAME, true); - const { history } = renderWithRouter(, { route: `/${TEST_ENTERPRISE.slug}` }); + const { history } = renderWithRouter(, { route: `/${TEST_ENTERPRISE.slug}` }); expect(history.location.pathname).toEqual(`/${TEST_ENTERPRISE.slug}`); }); }); 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 3075de4fd3..ff47fed63f 100644 --- a/src/components/enterprise-user-subsidy/enterprise-offers/data/constants.js +++ b/src/components/enterprise-user-subsidy/enterprise-offers/data/constants.js @@ -34,4 +34,5 @@ export const ASSIGNMENT_TYPES = { ACCEPTED: 'accepted', ALLOCATED: 'allocated', CANCELLED: 'cancelled', + ERRORED: 'errored', };