Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: modifies logic for setting cookies on dashboard page load #881

Merged
merged 11 commits into from
Dec 5, 2023
37 changes: 26 additions & 11 deletions src/components/dashboard/DashboardPage.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,17 +12,24 @@ 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 { ASSIGNMENT_TYPES } from './main-content/course-enrollments/CourseEnrollments';
import EnterpriseLearnerFirstVisitRedirect, {
isFirstDashboardPageVisit,
} 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,
redeemableLearnerCreditPolicies,
} = 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);
Expand All @@ -38,6 +39,17 @@ const DashboardPage = () => {
},
} = useEnterpriseCuration(enterpriseConfig.uuid);

const hasActiveCourseAssignments = (learnerCreditPolicies) => {
const learnerContentAssignmentsArray = learnerCreditPolicies?.flatMap(
item => item?.learnerContentAssignments || [],
);
// filters out course assignments that are not considered active
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion, non-blocking] Looks like logic to generate a flat map of assignments based on the learner credit policies happens in two places now. I'm wondering if it'd be worth abstracting this logic out to where we get the response about redeemable learner credit policies from the credits_available API so components such as this one don't need to run their own .flatMap to get a list of assignments.

E.g., useRedeemableLearnerCreditPolicies and its query function getRedeemablePoliciesData could return a data structure more along the lines of the following, where it splits out redeemablePolicies vs. learnerContentAssignments:

const getRedeemablePoliciesData = async ({ queryKey }) => {
  const enterpriseId = queryKey[1];
  const userID = queryKey[2];
  const response = await fetchRedeemableLearnerCreditPolicies(enterpriseId, userID);
  const redeemablePolicies = camelCaseObject(transformRedeemablePoliciesData(response.data));
  const learnerContentAssignments = redeemablePolicies.flatMap(policy => policy.learnerContentAssignments);
  return {
    redeemablePolicies,
    learnerContentAssignments,
  };
};

export function useRedeemableLearnerCreditPolicies(enterpriseId, userID) {
  return useQuery({
    queryKey: ['redeemablePolicies', enterpriseId, userID],
    queryFn: getRedeemablePoliciesData,
    onError: (error) => {
      logError(error);
    },
  });
}

Any component that relies on an assignments list could rely on learnerContentAssignments returned by the hook instead of having to re-compute the .flatMap each time. Then, the same line in the isDisableCourseSearch function could be simplified to:

const assignments = redeemableLearnerCreditPolicies?.learnerContentAssignments;

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opted to resolve this in a followup PR to focus on the MVP, but intended to resolve immediately following the validation of functionality of this PR in stage/prod environment. Updated ticket to document the decision.

const hasActiveAssignments = learnerContentAssignmentsArray.filter(
assignment => assignment.state !== ASSIGNMENT_TYPES.cancelled,
);
return hasActiveAssignments.length > 0;
};

const onSelectHandler = (key) => {
if (key === 'my-career') {
sendEnterpriseTrackEvent(
Expand Down Expand Up @@ -87,14 +99,17 @@ const DashboardPage = () => {
),
];

if (!hasActiveCourseAssignments(redeemableLearnerCreditPolicies) && isFirstDashboardPageVisit()) {
return <EnterpriseLearnerFirstVisitRedirect />;
}

brobro10000 marked this conversation as resolved.
Show resolved Hide resolved
return (
<>
<Helmet title={PAGE_TITLE} />
<Container size="lg">
<h2 className="h1 mb-4 mt-4">
{userFirstName ? `Welcome, ${userFirstName}!` : 'Welcome!'}
</h2>
<EnterpriseLearnerFirstVisitRedirect />
<Tabs defaultActiveKey="courses" onSelect={(k) => onSelectHandler(k)}>{allTabs.filter(tab => tab)}</Tabs>
{enterpriseConfig.showIntegrationWarning && <IntegrationWarningModal isOpen />}
{subscriptionPlan && showExpirationNotifications && <SubscriptionExpirationModal />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
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';
Expand Down Expand Up @@ -56,16 +58,15 @@
setAssignments(assignmentsData);

const hasCancelledAssignments = assignmentsData?.some(
assignment => assignment.state === ASSIGNMENT_TYPES.cancelled,

Check warning on line 61 in src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx#L61

Added line #L61 was not covered by tests
);
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);

Check warning on line 69 in src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx#L69

Added line #L69 was not covered by tests
const assignedCourses = getTransformedAllocatedAssignments(filteredAssignments, slug);

const currentCourseEnrollments = useMemo(
Expand All @@ -73,9 +74,9 @@
Object.keys(courseEnrollmentsByStatus).forEach((status) => {
courseEnrollmentsByStatus[status] = courseEnrollmentsByStatus[status].map((course) => {
const isAssigned = assignments?.some(assignment => (assignment?.state === ASSIGNMENT_TYPES.accepted
&& course.courseRunId.includes(assignment?.contentKey)));

Check warning on line 77 in src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx#L77

Added line #L77 was not covered by tests
if (isAssigned) {
return { ...course, isCourseAssigned: true };

Check warning on line 79 in src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx#L79

Added line #L79 was not covered by tests
}
return course;
});
Expand Down Expand Up @@ -118,10 +119,10 @@
return (
<>
{showCancelledAssignmentsAlert && (
<CourseAssignmentAlert variant="cancelled" onClose={() => setShowCancelledAssignmentsAlert(false)}> </CourseAssignmentAlert>

Check warning on line 122 in src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx#L122

Added line #L122 was not covered by tests
)}
{showExpiredAssignmentsAlert && (
<CourseAssignmentAlert variant="expired" onClose={() => setShowExpiredAssignmentsAlert(false)}> </CourseAssignmentAlert>

Check warning on line 125 in src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx#L125

Added line #L125 was not covered by tests
)}
{showMarkCourseCompleteSuccess && (
<CourseEnrollmentsAlert variant="success" onClose={() => setShowMarkCourseCompleteSuccess(false)}>
Expand All @@ -141,7 +142,7 @@
{(!hasCourseEnrollments && !hasCourseAssignments) && children}
<>
{features.FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT && (
<CourseSection

Check warning on line 145 in src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/dashboard/main-content/course-enrollments/CourseEnrollments.jsx#L145

Added line #L145 was not covered by tests
title={COURSE_SECTION_TITLES.assigned}
courseRuns={assignedCourses}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import {
useState, useEffect, useCallback,
} from 'react';
import { useCallback, useEffect, useState } from 'react';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { logError } from '@edx/frontend-platform/logging';
import _camelCase from 'lodash.camelcase';
Expand All @@ -11,8 +9,8 @@ import { groupCourseEnrollmentsByStatus, transformCourseEnrollment } from './uti
import { COURSE_STATUSES } from './constants';
import CourseService from '../../../../course/data/service';
import {
createEnrollWithLicenseUrl,
createEnrollWithCouponCodeUrl,
createEnrollWithLicenseUrl,
findCouponCodeForCourse,
findHighestLevelSeatSku,
getSubsidyToApplyForCourse,
Expand Down
99 changes: 81 additions & 18 deletions src/components/dashboard/tests/DashboardPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,15 @@ import { breakpoints } from '@edx/paragon';
import Cookies from 'universal-cookie';

import userEvent from '@testing-library/user-event';
import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils';
import { UserSubsidyContext } from '../../enterprise-user-subsidy';
import { CourseContextProvider } from '../../course/CourseContextProvider';
import {
SUBSCRIPTION_EXPIRED_MODAL_TITLE,
SUBSCRIPTION_EXPIRING_MODAL_TITLE,
} from '../SubscriptionExpirationModal';
import {
SEEN_SUBSCRIPTION_EXPIRATION_MODAL_COOKIE_PREFIX,
} from '../../../config/constants';
import { SUBSCRIPTION_EXPIRED_MODAL_TITLE, SUBSCRIPTION_EXPIRING_MODAL_TITLE } from '../SubscriptionExpirationModal';
import { SEEN_SUBSCRIPTION_EXPIRATION_MODAL_COOKIE_PREFIX } from '../../../config/constants';
import { features } from '../../../config';
import * as hooks from '../main-content/course-enrollments/data/hooks';

import {
renderWithRouter,
} from '../../../utils/tests';
import { renderWithRouter } from '../../../utils/tests';
import DashboardPage from '../DashboardPage';

import { LICENSE_ACTIVATION_MESSAGE } from '../data/constants';
Expand All @@ -31,6 +25,9 @@ import { LICENSE_STATUS } from '../../enterprise-user-subsidy/data/constants';
import { SubsidyRequestsContext } from '../../enterprise-subsidy-requests';
import { SUBSIDY_TYPE } from '../../enterprise-subsidy-requests/constants';
import { sortAssignmentsByAssignmentStatus } from '../main-content/course-enrollments/data/utils';
import EnterpriseLearnerFirstVisitRedirect, {
isFirstDashboardPageVisit,
} from '../../enterprise-redirects/EnterpriseLearnerFirstVisitRedirect';

const defaultCouponCodesState = {
couponCodes: [],
Expand All @@ -40,6 +37,11 @@ const defaultCouponCodesState = {

const mockAuthenticatedUser = { username: 'myspace-tom', name: 'John Doe' };

jest.mock('@edx/frontend-enterprise-utils', () => ({
...jest.requireActual('@edx/frontend-enterprise-utils'),
sendEnterpriseTrackEvent: jest.fn(),
}));

jest.mock('../../../config', () => ({
features: {
FEATURE_ENABLE_PATHWAY_PROGRESS: jest.fn(),
Expand All @@ -52,6 +54,8 @@ jest.mock('../main-content/course-enrollments/data/utils', () => ({
sortAssignmentsByAssignmentStatus: jest.fn(),
}));

jest.mock('../../enterprise-redirects/EnterpriseLearnerFirstVisitRedirect');

const defaultAppState = {
enterpriseConfig: {
name: 'BearsRUs',
Expand All @@ -68,6 +72,7 @@ const defaultAppState = {
const defaultUserSubsidyState = {
couponCodes: defaultCouponCodesState,
enterpriseOffers: [],
redeemableLearnerCreditPolicies: [],
};

const defaultCourseState = {
Expand Down Expand Up @@ -176,12 +181,13 @@ describe('<Dashboard />', () => {
});

beforeEach(() => {
jest.clearAllMocks();
sortAssignmentsByAssignmentStatus.mockReturnValue([]);
});

it('renders user first name if available', () => {
renderWithRouter(<DashboardWithContext />);
expect(screen.getByText('Welcome, John!'));
expect(screen.getByText('Welcome, John!')).toBeInTheDocument();
});

it('does not render user first name if not available', () => {
Expand All @@ -193,15 +199,15 @@ describe('<Dashboard />', () => {
},
};
renderWithRouter(<DashboardWithContext initialAppState={appState} />);
expect(screen.getByText('Welcome!'));
expect(screen.getByText('Welcome!')).toBeInTheDocument();
});

it('renders license activation alert on activation success', () => {
renderWithRouter(
<DashboardWithContext />,
{ 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', () => {
Expand All @@ -218,7 +224,7 @@ describe('<Dashboard />', () => {
renderWithRouter(
<DashboardWithContext />,
);
expect(screen.getByTestId('sidebar'));
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
});

it('renders subsidies summary on a small screen', () => {
Expand All @@ -233,14 +239,14 @@ describe('<Dashboard />', () => {
}}
/>,
);
expect(screen.getByTestId('subsidies-summary'));
expect(screen.getByTestId('subsidies-summary')).toBeInTheDocument();
});

it('renders "Find a course" when search is enabled for the customer', () => {
renderWithRouter(
<DashboardWithContext />,
);
expect(screen.getByText('Find a course'));
expect(screen.getByText('Find a course')).toBeInTheDocument();
});

it('renders Pathways when feature is enabled', () => {
Expand All @@ -258,15 +264,15 @@ describe('<Dashboard />', () => {
renderWithRouter(
<DashboardWithContext initialAppState={appState} />,
);
expect(screen.getByText('Pathways'));
expect(screen.getByText('Pathways')).toBeInTheDocument();
});

it('renders My Career when feature is enabled', () => {
features.FEATURE_ENABLE_MY_CAREER.mockImplementation(() => true);
renderWithRouter(
<DashboardWithContext />,
);
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', () => {
Expand Down Expand Up @@ -331,6 +337,63 @@ describe('<Dashboard />', () => {
expect(programsTab).toHaveAttribute('aria-selected', 'false');
});

it('should send track event when "my-career" tab selected', () => {
renderWithRouter(<DashboardWithContext />);

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', () => {
isFirstDashboardPageVisit.mockReturnValueOnce(true);
EnterpriseLearnerFirstVisitRedirect.mockImplementation(() => (
<IntlProvider locale="en">
<div>search-page</div>
</IntlProvider>
));

const noActiveCourseAssignmentUserSubsidyState = {
...defaultUserSubsidyState,
redeemableLearnerCreditPolicies: [{
learnerContentAssignments: [{
state: 'cancelled',
},
],
}],
};
renderWithRouter(<DashboardWithContext initialUserSubsidyState={noActiveCourseAssignmentUserSubsidyState} />);

expect(isFirstDashboardPageVisit).toHaveBeenCalled();
expect(screen.getByText('search-page')).toBeInTheDocument();
});

it('should not render redirect component if active learnerContentAssignment exist', () => {
isFirstDashboardPageVisit.mockReturnValueOnce(true);
EnterpriseLearnerFirstVisitRedirect.mockImplementation(() => (
<IntlProvider locale="en">
<div>search-page</div>
</IntlProvider>
));

const noActiveCourseAssignmentUserSubsidyState = {
...defaultUserSubsidyState,
redeemableLearnerCreditPolicies: [{
learnerContentAssignments: [{
state: 'allocated',
},
{
state: 'accepted',
},
],
}],
};
renderWithRouter(<DashboardWithContext initialUserSubsidyState={noActiveCourseAssignmentUserSubsidyState} />);

expect(screen.queryByText('search-page')).not.toBeInTheDocument();
});

describe('SubscriptionExpirationModal', () => {
it('should not render when > 60 days of access remain', () => {
renderWithRouter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,24 @@ import React, { useEffect } from 'react';
import { Redirect, useParams } from 'react-router-dom';
import Cookies from 'universal-cookie';

export const isFirstDashboardPageVisit = () => {
const cookies = new Cookies();

const hasUserVisitedDashboard = cookies.get('has-user-visited-learner-dashboard');
return !hasUserVisitedDashboard;
};

const EnterpriseLearnerFirstVisitRedirect = () => {
const { enterpriseSlug } = useParams();

const cookies = new Cookies();

const isFirstVisit = () => {
const hasUserVisitedDashboard = cookies.get('has-user-visited-learner-dashboard');
return !hasUserVisitedDashboard;
};

useEffect(() => {
if (isFirstVisit()) {
if (isFirstDashboardPageVisit()) {
cookies.set('has-user-visited-learner-dashboard', true, { path: '/' });
}
});
brobro10000 marked this conversation as resolved.
Show resolved Hide resolved

if (isFirstVisit()) {
if (isFirstDashboardPageVisit()) {
return <Redirect to={`/${enterpriseSlug}/search`} />;
}

Expand Down
Loading