Skip to content

Commit

Permalink
build: Add typescript support
Browse files Browse the repository at this point in the history
build: Use updated frontend-build typescript config

chore: Merge from master and reconcile with new typescript lint config

chore: Remove unnecessary typescript packages and fix tsconfig formatting
marlonkeating committed Apr 19, 2023
1 parent 97914c3 commit c9e8c26
Showing 38 changed files with 4,799 additions and 4,208 deletions.
2 changes: 1 addition & 1 deletion .env.development
Original file line number Diff line number Diff line change
@@ -45,7 +45,7 @@ FEATURE_ENABLE_AUTO_APPLIED_LICENSES='true'
LEARNING_TYPE_FACET='true'
LEARNER_SUPPORT_URL='https://support.edx.org/hc/en-us'
FEATURE_ENABLE_PATHWAYS='true'
FEATURE_ENABLE_COURSE_REVIEW='true'
FEATURE_ENABLE_COURSE_REVIEW=''
FEATURE_ENROLL_WITH_ENTERPRISE_OFFERS='true'
FEATURE_ENABLE_PATHWAY_PROGRESS='true'
GETSMARTER_STUDENT_TC_URL='https://www.getsmarter.com/terms-and-conditions-for-students'
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ const config = getBaseConfig('eslint');

config.overrides = [
{
files: ['*.test.js', '*.test.jsx'],
files: ['*.test.js', '*.test.jsx', '*.test.ts', '*.test.tsx'],
rules: {
'react/prop-types': 'off',
'react/jsx-no-constructed-context-values': 'off',
44 changes: 44 additions & 0 deletions docs/decisions/0008-introducing-typescript.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Introducing TypeScript

## Status

Accepted

## Context

TypeScript is a strongly-typed superset of JavaScript that adds optional static type checking, class-based object-oriented programming, and other features to JavaScript.

As we start to expand the scope of the data that Learner Portal uses, the limitations of plain Javascript are coming more into focus. In order to support a changing landscape of course data, we would like to introduce TypeScript into the code base to facilitate the documentation and refactoring process.

Here are some of the advantages of TypeScript over JavaScript:

### Type safety
TypeScript helps catch errors at compile-time instead of runtime by adding type annotations to variables, functions, and classes. This can help prevent errors that might occur when dealing with large codebases.

### Better tooling support
TypeScript has better tooling support than JavaScript, with features like code navigation, auto-completion, and refactoring tools in popular code editors like Visual Studio Code.

### Improved code organization
TypeScript's class-based object-oriented programming model allows developers to organize code in a more structured and maintainable way.

### Easy adoption
TypeScript is a superset of JavaScript, which means that any valid JavaScript code is also valid TypeScript code. This makes it easy for developers to adopt TypeScript gradually, without needing to rewrite their entire codebase.

### Community support
TypeScript has a growing community of developers who contribute to its development, create libraries and tools, and provide support to other developers. This makes it easier for developers to learn and adopt TypeScript.

## Decision

We will prioritize using TypeScript in the following places:
* New code files
* Existing API endpoints (and their payloads)
* Components or Functions take a lot of parameters, or use parameters that are themselves complex objects

## Consequences

* Code that requires heavy contracts, whether that's functions/components with lots or parameters, or complex objects returned from backend API's, will become much more comprehensible and easier to work with in a modern programming IDE
* Because TypeScript is a superset of Javascript, the code does not need to be migrated all at once, but can be updated to TypeScript during the course of regular feature work.

## References

* https://www.typescriptlang.org/
27 changes: 27 additions & 0 deletions external.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// TODO: Add definition to @edx/frontend-platform/auth
export interface HttpClient {
get: (url: string) => any;
post: (url: string, options: any) => any;
};

// TODO: Add definition to @edx/frontend-platform/config
export type FrontendPlatformConfig = {
USE_API_CACHE: boolean;
ENTERPRISE_CATALOG_API_BASE_URL: string;
DISCOVERY_API_BASE_URL: string;
LMS_BASE_URL: string;
LICENSE_MANAGER_URL: string;
};

// TODO: Add definitions to @edx/frontend-platform/react
export type EnterpriseConfig = {
uuid: string;
name: string;
slug: string;
disableSearch: boolean;
adminUsers: any[]
};

export type ReactAppContext = {
enterpriseConfig: EnterpriseConfig;
};
4 changes: 4 additions & 0 deletions globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module '*.svg' {
const content: string;
export default content;
}
7,228 changes: 3,702 additions & 3,526 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -36,6 +36,7 @@
"lodash.capitalize": "4.2.1",
"lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8",
"lodash.isnumber": "^3.0.3",
"moment": "2.29.1",
"plotly.js-dist": "^2.17.0",
"prop-types": "15.7.2",
@@ -59,7 +60,7 @@
},
"devDependencies": {
"@edx/browserslist-config": "1.1.1",
"@edx/frontend-build": "12.4.15",
"@edx/frontend-build": "12.9.0-alpha.1",
"@testing-library/jest-dom": "5.11.9",
"@testing-library/react": "11.2.7",
"@testing-library/react-hooks": "3.7.0",
4 changes: 2 additions & 2 deletions src/components/course/CourseHeader.jsx
Original file line number Diff line number Diff line change
@@ -22,7 +22,6 @@ import { useCoursePartners } from './data/hooks';
import LicenseRequestedAlert from './LicenseRequestedAlert';
import SubsidyRequestButton from './SubsidyRequestButton';
import CourseReview from './CourseReview';
import { features } from '../../config';

const CourseHeader = () => {
const { enterpriseConfig } = useContext(AppContext);
@@ -34,6 +33,7 @@ const CourseHeader = () => {
() => getDefaultProgram(course.programs),
[course],
);
const enableReviewSection = false;

return (
<div className="course-header">
@@ -99,7 +99,7 @@ const CourseHeader = () => {
<Col xs={12} lg={12}>
{catalog.containsContentItems ? (
<>
{features.ENABLE_COURSE_REVIEW && <CourseReview />}
{enableReviewSection && <CourseReview />}
{defaultProgram && (
<p className="font-weight-bold mt-3 mb-0">
This course is part of a {formatProgramType(defaultProgram.type)}.
17 changes: 4 additions & 13 deletions src/components/course/CourseRunCard.jsx
Original file line number Diff line number Diff line change
@@ -8,7 +8,6 @@ import {

import { AppContext } from '@edx/frontend-platform/react';
import { useLocation } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform/config';
import EnrollAction from './enrollment/EnrollAction';
import { enrollButtonTypes } from './enrollment/constants';
import {
@@ -26,7 +25,6 @@ import {
numberWithPrecision,
} from './data/utils';
import { formatStringAsNumber } from '../../utils/common';
import { isExperimentVariant } from '../../utils/optimizely';

import { useSubsidyDataForCourse } from './enrollment/hooks';
import { useCourseEnrollmentUrl, useUserHasSubsidyRequestForCourse, useCoursePriceForUserSubsidy } from './data/hooks';
@@ -72,8 +70,6 @@ const CourseRunCard = ({
isEnrollable,
} = courseRun;

const config = getConfig();

const location = useLocation();

const { enterpriseConfig } = useContext(AppContext);
@@ -226,10 +222,6 @@ const CourseRunCard = ({
&& enrollmentType === enrollButtonTypes.TO_DATASHARING_CONSENT
&& userSubsidyApplicableToCourse?.subsidyType === LICENSE_SUBSIDY_TYPE
);
// Only users buckted in `Variation 1` can see the change.
const isExperimentVariation1 = isExperimentVariant(config.EXPERIMENT_2_ID, config.EXPERIMENT_2_VARIANT_1_ID);
// For our experiment, we should trigger our Optimizely event only when this condition is true
const triggerLicenseSubsidyEvent = shouldShowLicenseSubsidyPriceText;

return (
<Card>
@@ -245,7 +237,7 @@ const CourseRunCard = ({
>
<div className="h4 mb-0">{heading}</div>
<div className="small">{subHeading}</div>
{isExperimentVariation1 && shouldShowLicenseSubsidyPriceText && (
{shouldShowLicenseSubsidyPriceText && (
<LicenseSubsidyPriceText
courseRun={courseRun}
userSubsidyApplicableToCourse={userSubsidyApplicableToCourse}
@@ -259,8 +251,7 @@ const CourseRunCard = ({
enrollmentUrl={enrollmentUrl}
userEnrollment={userEnrollment}
subscriptionLicense={subscriptionLicense}
triggerLicenseSubsidyEvent={triggerLicenseSubsidyEvent}
courseRunPrice={courseRun.firstEnrollablePaidSeatPrice}
courseRunPrice={courseRun?.firstEnrollablePaidSeatPrice}
/>
)}
</Card.Section>
@@ -279,7 +270,7 @@ CourseRunCard.propTypes = {
start: PropTypes.string.isRequired,
key: PropTypes.string.isRequired,
seats: PropTypes.arrayOf(PropTypes.shape()).isRequired,
firstEnrollablePaidSeatPrice: PropTypes.number.isRequired,
firstEnrollablePaidSeatPrice: PropTypes.number,
}).isRequired,
userEnrollments: PropTypes.arrayOf(PropTypes.shape({
isEnrollmentActive: PropTypes.bool.isRequired,
@@ -301,7 +292,7 @@ LicenseSubsidyPriceText.propTypes = {
start: PropTypes.string.isRequired,
key: PropTypes.string.isRequired,
seats: PropTypes.arrayOf(PropTypes.shape()).isRequired,
firstEnrollablePaidSeatPrice: PropTypes.number.isRequired,
firstEnrollablePaidSeatPrice: PropTypes.number,
}).isRequired,
userSubsidyApplicableToCourse: PropTypes.shape({
discountType: PropTypes.string.isRequired,
4 changes: 2 additions & 2 deletions src/components/course/CourseSidebarPrice.jsx
Original file line number Diff line number Diff line change
@@ -22,11 +22,11 @@ export const INSUFFICIENT_ENTERPRISE_OFFER_BALANCE = 'Your organization doesn\'t
const CourseSidebarPrice = () => {
const { enterpriseConfig } = useContext(AppContext);
const { state: courseData } = useContext(CourseContext);
const { activeCourseRun, userSubsidyApplicableToCourse } = courseData;
const { course, activeCourseRun, userSubsidyApplicableToCourse } = courseData;
const { subsidyRequestConfiguration } = useContext(SubsidyRequestsContext);

const [coursePrice, currency] = useCoursePriceForUserSubsidy({
activeCourseRun, userSubsidyApplicableToCourse,
courseEntitlements: course?.entitlements, activeCourseRun, userSubsidyApplicableToCourse,
});

const {

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -3,9 +3,42 @@ import { camelCaseObject } from '@edx/frontend-platform/utils';
import { getConfig } from '@edx/frontend-platform/config';

import { getActiveCourseRun, getAvailableCourseRuns } from './utils';
import { FrontendPlatformConfig, HttpClient } from '../../../../external';
import {
CourseData,
CourseRun,
CourseServiceOptions,
CourseRecommendation,
CourseEnrollment,
UserEntitlement,
CatalogData,
EnterpriseSubsidy,
} from './types';

export default class CourseService {
constructor(options = {}) {
export type AllCourseData = {
courseDetails: CourseData;
userEnrollments: CourseEnrollment[];
userEntitlements: UserEntitlement[];
catalog: CatalogData;
userSubsidyApplicableToCourse?: EnterpriseSubsidy | null;
};

export default class CourseService implements CourseServiceOptions {
activeCourseRun?: CourseRun;

courseKey?: string;

courseRunKey?: string;

enterpriseUuid?: string;

config: FrontendPlatformConfig;

authenticatedHttpClient: HttpClient;

cachedAuthenticatedHttpClient: HttpClient;

constructor(options: CourseServiceOptions = {}) {
const {
activeCourseRun,
courseKey,
@@ -25,13 +58,13 @@ export default class CourseService {
this.activeCourseRun = activeCourseRun;
}

async fetchAllCourseData() {
async fetchAllCourseData(): Promise<AllCourseData> {
const courseDataRaw = await Promise.all([
this.fetchCourseDetails(),
this.fetchUserEnrollments(),
this.fetchUserEntitlements(),
this.fetchEnterpriseCustomerContainsContent(),
]).then((responses) => responses.map(res => res.data));
]).then((responses) => responses.map((res) => res.data));

const courseData = camelCaseObject(courseDataRaw);
const courseDetails = courseData[0];
@@ -46,7 +79,9 @@ export default class CourseService {
if (availableCourseRunKeys.includes(this.courseRunKey)) {
courseDetails.canonicalCourseRunKey = this.courseRunKey;
courseDetails.courseRunKeys = [this.courseRunKey];
courseDetails.courseRuns = availableCourseRuns.filter(obj => obj.key === this.courseRunKey);
courseDetails.courseRuns = availableCourseRuns.filter(
(obj) => obj.key === this.courseRunKey,
);
courseDetails.advertisedCourseRunUuid = courseDetails.courseRuns[0].uuid;
}
}
@@ -59,39 +94,56 @@ export default class CourseService {
};
}

async fetchAllCourseRecommendations(activeCatalogs = []) {
const resp = await this.fetchCourseRecommendations()
.then(async (response) => {
async fetchAllCourseRecommendations(
activeCatalogs = [],
): Promise<CourseRecommendation[]> {
const resp = await this.fetchCourseRecommendations().then(
async (response) => {
const {
all_recommendations: allRecommendations,
same_partner_recommendations: samePartnerRecommendations,
} = response.data;

// handle no recommendations case
if (allRecommendations.length < 1 && samePartnerRecommendations.length < 1) {
if (
allRecommendations.length < 1
&& samePartnerRecommendations.length < 1
) {
return response.data;
}
const allRecommendationsKeys = allRecommendations?.map((rec) => rec.key);
const samePartnerRecommendationsKeys = samePartnerRecommendations?.map((rec) => rec.key);
const allRecommendationsKeys = allRecommendations?.map(
(rec) => rec.key,
);
const samePartnerRecommendationsKeys = samePartnerRecommendations?.map(
(rec) => rec.key,
);

const options = {
content_keys: allRecommendationsKeys.concat(samePartnerRecommendationsKeys),
content_keys: allRecommendationsKeys.concat(
samePartnerRecommendationsKeys,
),
catalog_uuids: activeCatalogs,
};

const filteredRecommendations = await this.fetchFilteredRecommendations(options);
const { filtered_content_keys: filteredContentKeys } = filteredRecommendations.data;
const filteredRecommendations = await this.fetchFilteredRecommendations(
options,
);
const {
filtered_content_keys: filteredContentKeys,
} = filteredRecommendations.data;

const recommendations = {
all_recommendations: allRecommendations.filter(
(rec) => !samePartnerRecommendationsKeys.includes(rec.key) && filteredContentKeys.includes(rec.key),
(rec) => !samePartnerRecommendationsKeys.includes(rec.key)
&& filteredContentKeys.includes(rec.key),
),
same_partner_recommendations: samePartnerRecommendations.filter(
(rec) => filteredContentKeys.includes(rec.key),
),
};
return recommendations;
});
},
);
return resp;
}

@@ -100,7 +152,7 @@ export default class CourseService {
return this.cachedAuthenticatedHttpClient.post(url, options);
}

fetchCourseDetails() {
fetchCourseDetails(): CourseData {
const url = `${this.config.DISCOVERY_API_BASE_URL}/api/v1/courses/${this.courseKey}/`;
return this.cachedAuthenticatedHttpClient.get(url);
}
@@ -118,10 +170,12 @@ export default class CourseService {
fetchUserEnrollments() {
const queryParams = new URLSearchParams({
enterprise_id: this.enterpriseUuid,
is_active: true,
});
is_active: 'true',
} as Record<string, string>);
const config = getConfig();
const url = `${config.LMS_BASE_URL}/enterprise_learner_portal/api/v1/enterprise_course_enrollments/?${queryParams.toString()}`;
const url = `${
config.LMS_BASE_URL
}/enterprise_learner_portal/api/v1/enterprise_course_enrollments/?${queryParams.toString()}`;
return getAuthenticatedHttpClient().get(url);
}

@@ -134,21 +188,27 @@ export default class CourseService {
// This API call will *only* obtain the enterprise's catalogs whose
// catalog queries return/contain the specified courseKey.
const queryParams = new URLSearchParams({
course_run_ids: courseRunIds,
get_catalogs_containing_specified_content_ids: true,
});

const url = `${this.config.ENTERPRISE_CATALOG_API_BASE_URL}/api/v1/enterprise-customer/${this.enterpriseUuid}/contains_content_items/?${queryParams.toString()}`;
course_run_ids: courseRunIds.toString(),
get_catalogs_containing_specified_content_ids: 'true',
} as Record<string, string>);

const url = `${
this.config.ENTERPRISE_CATALOG_API_BASE_URL
}/api/v1/enterprise-customer/${
this.enterpriseUuid
}/contains_content_items/?${queryParams.toString()}`;
return this.cachedAuthenticatedHttpClient.get(url);
}

fetchUserLicenseSubsidy(courseKey = this.activeCourseRun.key) {
fetchUserLicenseSubsidy(courseKey = this?.activeCourseRun?.key) {
const queryParams = new URLSearchParams({
enterprise_customer_uuid: this.enterpriseUuid,
course_key: courseKey,
});
const url = `${this.config.LICENSE_MANAGER_URL}/api/v1/license-subsidy/?${queryParams.toString()}`;
return this.cachedAuthenticatedHttpClient.get(url).catch(error => {
} as Record<string, string>);
const url = `${
this.config.LICENSE_MANAGER_URL
}/api/v1/license-subsidy/?${queryParams.toString()}`;
return this.cachedAuthenticatedHttpClient.get(url).catch((error) => {
const httpErrorStatus = error.customAttributes?.httpErrorStatus;
if (httpErrorStatus === 404) {
// 404 means the user's license is not applicable for the course, return undefined instead of throwing an error

Large diffs are not rendered by default.

23 changes: 17 additions & 6 deletions src/components/course/data/tests/service.test.js
Original file line number Diff line number Diff line change
@@ -3,7 +3,10 @@ import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

import CourseService from '../service';
import { TEST_RECOMMENDATION_DATA, FILTERED_RECOMMENDATIONS } from '../../tests/constants';
import {
TEST_RECOMMENDATION_DATA,
FILTERED_RECOMMENDATIONS,
} from '../../tests/constants';

// config
const APP_CONFIG = {
@@ -24,11 +27,15 @@ const FILTER_RECOMMENDATION_API_ENDPOINT = `${APP_CONFIG.ENTERPRISE_CATALOG_API_
jest.mock('@edx/frontend-platform/auth');
const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);
axiosMock.onGet(RECOMMENDATION_API_ENDPOINT).reply(200, TEST_RECOMMENDATION_DATA);
axiosMock.onPost(FILTER_RECOMMENDATION_API_ENDPOINT).reply(200, FILTERED_RECOMMENDATIONS);
axiosMock
.onGet(RECOMMENDATION_API_ENDPOINT)
.reply(200, TEST_RECOMMENDATION_DATA);
axiosMock
.onPost(FILTER_RECOMMENDATION_API_ENDPOINT)
.reply(200, FILTERED_RECOMMENDATIONS);

jest.mock('@edx/frontend-platform/config', () => ({
getConfig: () => (APP_CONFIG),
getConfig: () => APP_CONFIG,
}));

describe('CourseService', () => {
@@ -59,10 +66,14 @@ describe('CourseService', () => {
// based on what we get from filtered recommendations API [FILTERED_RECOMMENDATIONS], expected data should be:
const expectedData = {
all_recommendations: [TEST_RECOMMENDATION_DATA.all_recommendations[0]],
same_partner_recommendations: [TEST_RECOMMENDATION_DATA.same_partner_recommendations[1]],
same_partner_recommendations: [
TEST_RECOMMENDATION_DATA.same_partner_recommendations[1],
],
};
expect(axiosMock.history.get[0].url).toBe(RECOMMENDATION_API_ENDPOINT);
expect(axiosMock.history.post[0].url).toBe(FILTER_RECOMMENDATION_API_ENDPOINT);
expect(axiosMock.history.post[0].url).toBe(
FILTER_RECOMMENDATION_API_ENDPOINT,
);

expect(data).toEqual(expectedData);
});
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
import moment from 'moment';
import { ENTERPRISE_OFFER_TYPE } from '../../../enterprise-user-subsidy/enterprise-offers/data/constants';
import { COUPON_CODE_SUBSIDY_TYPE, ENTERPRISE_OFFER_SUBSIDY_TYPE, LICENSE_SUBSIDY_TYPE } from '../constants';
import { findCouponCodeForCourse, findEnterpriseOfferForCourse, getSubsidyToApplyForCourse } from '../utils';
import {
COUPON_CODE_SUBSIDY_TYPE,
ENTERPRISE_OFFER_SUBSIDY_TYPE,
LICENSE_SUBSIDY_TYPE,
} from '../constants';
import {
SubscriptionLicense, CouponCode,
EnterpriseOffer,
} from '../types';
import {
findCouponCodeForCourse,
findEnterpriseOfferForCourse,
getSubsidyToApplyForCourse,
courseUsesEntitlementPricing,
} from '../utils';

jest.mock('@edx/frontend-platform/config', () => ({
getConfig: () => ({ COURSE_TYPES_WITH_ENTITLEMENT_LIST_PRICE: ['entitlement_course'] }),
}));

describe('findCouponCodeForCourse', () => {
const couponCodes = [{
code: 'bearsRus',
catalog: 'bears',
couponStartDate: moment().subtract(1, 'w').toISOString(),
couponEndDate: moment().add(8, 'w').toISOString(),
}];
const couponCodes: CouponCode[] = [
{
uuid: '',
code: 'bearsRus',
catalog: 'bears',
couponStartDate: moment().subtract(1, 'w').toISOString(),
couponEndDate: moment().add(8, 'w').toISOString(),
usageType: '',
benefitValue: 0,
},
];

test('returns valid index if coupon code catalog is in catalog list', () => {
const catalogList = ['cats', 'bears'];
expect(findCouponCodeForCourse(couponCodes, catalogList)).toEqual(couponCodes[0]);
expect(findCouponCodeForCourse(couponCodes, catalogList)).toEqual(
couponCodes[0],
);
});

test('returns undefined if catalog list is empty', () => {
@@ -22,69 +46,89 @@ describe('findCouponCodeForCourse', () => {
});

describe('findEnterpriseOfferForCourse', () => {
const enterpriseOffers = [
{
enterpriseCatalogUuid: 'cats',
},
{
enterpriseCatalogUuid: 'horses',
},
{
enterpriseCatalogUuid: 'cats',
},
];
const couponStartDate = moment().subtract(1, 'w').toISOString();
const couponEndDate = moment().add(8, 'w').toISOString();
const enterpriseOffers: EnterpriseOffer[] = ['cats', 'horses', 'cats'].map(animal => ({
enterpriseCatalogUuid: animal,
couponStartDate,
couponEndDate,
startDatetime: '',
endDatetime: '',
discountType: '',
discountValue: 0,
offerType: '',
usageType: '',
id: 0,
}));

it('returns undefined if there is no course price', () => {
const catalogList = ['cats', 'bears'];
expect(findEnterpriseOfferForCourse({
enterpriseOffers, catalogList,
})).toBeUndefined();
expect(
findEnterpriseOfferForCourse({
enterpriseOffers,
catalogList,
coursePrice: 0,
}),
).toBeUndefined();
});

it('returns undefined if there is no enterprise offer for the course', () => {
const catalogList = ['pigs'];
expect(findEnterpriseOfferForCourse({
enterpriseOffers, catalogList, coursePrice: 100,
})).toBeUndefined();
expect(
findEnterpriseOfferForCourse({
enterpriseOffers,
catalogList,
coursePrice: 100,
}),
).toBeUndefined();
});

describe('offerType = (BOOKINGS_LIMIT || BOOKINGS_AND_ENROLLMENTS_LIMIT)', () => {
it.each([
ENTERPRISE_OFFER_TYPE.BOOKINGS_LIMIT,
ENTERPRISE_OFFER_TYPE.BOOKINGS_AND_ENROLLMENTS_LIMIT,
])('returns the enterprise offer with a valid catalog that has remaining balance >= course price', (
offerType,
) => {
const catalogList = ['cats', 'bears'];
expect(findEnterpriseOfferForCourse({
enterpriseOffers: enterpriseOffers.map(offer => ({
...offer, offerType, remainingBalance: 100,
})),
catalogList,
coursePrice: 100,
})).toStrictEqual({
...enterpriseOffers[2],
offerType,
remainingBalance: 100,
});
});
])(
'returns the enterprise offer with a valid catalog that has remaining balance >= course price',
(offerType) => {
const catalogList = ['cats', 'bears'];
expect(
findEnterpriseOfferForCourse({
enterpriseOffers: enterpriseOffers.map((offer) => ({
...offer,
offerType,
remainingBalance: 100,
remainingBalanceForUser: 100,
})),
catalogList,
coursePrice: 100,
}),
).toStrictEqual({
...enterpriseOffers[2],
offerType,
remainingBalance: 100,
remainingBalanceForUser: 100,
});
},
);
});

describe('offerType = (NO_LIMIT || ENROLLMENTS_LIMIT)', () => {
it.each([
ENTERPRISE_OFFER_TYPE.NO_LIMIT,
ENTERPRISE_OFFER_TYPE.ENROLLMENTS_LIMIT,
])('returns the enterprise offer with a valid catalog', (
offerType,
) => {
])('returns the enterprise offer with a valid catalog', (offerType) => {
const catalogList = ['cats', 'bears'];
expect(findEnterpriseOfferForCourse({
enterpriseOffers: enterpriseOffers.map(offer => ({
...offer, offerType, maxGlobalApplications: 100,
})),
catalogList,
coursePrice: 100,
})).toStrictEqual({
expect(
findEnterpriseOfferForCourse({
enterpriseOffers: enterpriseOffers.map((offer) => ({
...offer,
offerType,
maxGlobalApplications: 100,
})),
catalogList,
coursePrice: 100,
}),
).toStrictEqual({
...enterpriseOffers[2],
offerType,
maxGlobalApplications: 100,
@@ -94,25 +138,30 @@ describe('findEnterpriseOfferForCourse', () => {
});

describe('getSubsidyToApplyForCourse', () => {
const mockApplicableSubscriptionLicense = {
const mockApplicableSubscriptionLicense: SubscriptionLicense = {
uuid: 'license-uuid',
subsidyType: '',
};

const mockApplicableCouponCode = {
const mockApplicableCouponCode: CouponCode = {
uuid: 'coupon-code-uuid',
usageType: 'percentage',
benefitValue: 100,
couponStartDate: '2023-08-11',
couponEndDate: '2024-08-11',
code: 'xyz',
catalog: '',
};

const mockApplicableEnterpriseOffer = {
const mockApplicableEnterpriseOffer: EnterpriseOffer = {
id: 1,
usageType: 'Percentage',
discountValue: 100,
startDatetime: '2023-08-11',
endDatetime: '2024-08-11',
discountType: 'testDiscountType',
enterpriseCatalogUuid: '',
offerType: 'testOfferType',
};

it('returns applicableSubscriptionLicense', () => {
@@ -158,6 +207,7 @@ describe('getSubsidyToApplyForCourse', () => {
startDate: mockApplicableEnterpriseOffer.startDatetime,
endDate: mockApplicableEnterpriseOffer.endDatetime,
subsidyType: ENTERPRISE_OFFER_SUBSIDY_TYPE,
offerType: 'testOfferType',
});
});

@@ -171,3 +221,21 @@ describe('getSubsidyToApplyForCourse', () => {
expect(subsidyToApply).toBeNull();
});
});

describe('courseUsesEntitlementPricing', () => {
const mockEntitlementCourse = {
courseType: 'entitlement_course',
};

const mockNonEntitlementCourse = {
courseType: 'non_entitlement_course',
};

it('Returns true when course type included in COURSE_TYPES_WITH_ENTITLEMENT_LIST_PRICE', () => {
expect(courseUsesEntitlementPricing(mockEntitlementCourse)).toEqual(true);
});

it('Returns false when course type not included in COURSE_TYPES_WITH_ENTITLEMENT_LIST_PRICE', () => {
expect(courseUsesEntitlementPricing(mockNonEntitlementCourse)).toEqual(false);
});
});
121 changes: 121 additions & 0 deletions src/components/course/data/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
export type CoursePartner = {
name: string;
logoImageUrl: string;
};

export type CourseOwner = {
uuid: string;
key: string;
name: string;
slug: string;
auto_generate_course_run_keys: boolean;
certificate_logo_image_url?: string;
logo_image_url?: string;
organization_hex_color?: string;
description: string;
description_es?: string;
homepage_url?: string;
tags: string[];
marketing_url: string;
banner_image_url?: string;
enterprise_subscription_inclusion: boolean;
};

export type Course = {
title: string;
cardImageUrl: string;
originalImageUrl?: string;
key: string;
partners: CoursePartner[];
skillNames: string[];
owners: CourseOwner[];
};

export type SubscriptionLicense = {
uuid: string;
subsidyType: string;
};

export type CourseRun = {
key: string;
uuid: string;
title: string;
firstEnrollablePaidSeatPrice: number
};

export interface CourseServiceOptions {
activeCourseRun?: CourseRun;
courseKey?: string;
courseRunKey?: string;
enterpriseUuid?: string;
}

export type CourseRecommendation = {
key: string;
};

export type CouponCode = {
uuid: string;
code: string;
catalog: string;
usageType: string;
benefitValue: number;
couponStartDate: string;
couponEndDate: string;
};

export type EnterpriseOffer = {
id: number;
enterpriseCatalogUuid: string;
remainingBalance?: number;
remainingBalanceForUser?: number;
startDatetime: string;
endDatetime: string;
offerType: string;
usageType: string;
discountType: string;
discountValue: number;
};

export type EnterpriseSubsidy = {
discountType?: string;
discountValue?: number;
startDate?: string;
endDate?: string;
code?: string;
offerType?: string;
subsidyType: string;
};

export type CourseEnrollment = {
isEnrollmentActive: boolean;
isRevoked: boolean;
courseRunId: string;
mode: string;
};

export type UserEntitlement = {
courseUuid: string;
};

export type CatalogData = {
containsContentItems: boolean;
catalogList: string[];
};

export type CourseEntitlement = {
mode: string;
price: string;
currency: string;
sku: string;
expires?: string | null;
};

// TODO: Flesh out as needed
export type CourseData = {
key: string;
uuid: string;
title: string;
userSubsidyApplicableToCourse?: EnterpriseSubsidy | null;
entitlements: CourseEntitlement[];
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import isNumber from 'lodash.isnumber';

import { getConfig } from '@edx/frontend-platform';
import {
@@ -18,9 +19,15 @@ import VerifiedSvgIcon from '../../../assets/icons/verified.svg';
import XSeriesSvgIcon from '../../../assets/icons/xseries.svg';
import CreditSvgIcon from '../../../assets/icons/credit.svg';
import { PROGRAM_TYPE_MAP } from '../../program/data/constants';
import { programIsMicroMasters, programIsProfessionalCertificate } from '../../program/data/utils';
import {
programIsMicroMasters,
programIsProfessionalCertificate,
} from '../../program/data/utils';
import { hasValidStartExpirationDates } from '../../../utils/common';
import { offerHasBookingsLimit } from '../../enterprise-user-subsidy/enterprise-offers/data/utils';
import {
CouponCode, EnterpriseOffer, EnterpriseSubsidy, SubscriptionLicense,
} from './types';

export function hasCourseStarted(start) {
const today = new Date();
@@ -30,11 +37,7 @@ export function hasCourseStarted(start) {

export function findUserEnrollmentForCourseRun({ userEnrollments, key }) {
return userEnrollments.find(
({
isEnrollmentActive,
isRevoked,
courseRunId,
}) => (isEnrollmentActive && !isRevoked && courseRunId === key),
({ isEnrollmentActive, isRevoked, courseRunId }) => isEnrollmentActive && !isRevoked && courseRunId === key,
);
}

@@ -45,7 +48,7 @@ export function isUserEntitledForCourse({ userEntitlements, courseUuid }) {
export function weeksRemainingUntilEnd(courseRun) {
const today = new Date();
const end = new Date(courseRun.end);
const secondsDifference = Math.abs(end - today) / 1000;
const secondsDifference = Math.abs(end.valueOf() - today.valueOf()) / 1000;
const days = Math.floor(secondsDifference / 86400);
return Math.floor(days / 7);
}
@@ -95,9 +98,14 @@ export function formatProgramType(programType) {
switch (programType) {
case PROGRAM_TYPE_MAP.MICROMASTERS:
case PROGRAM_TYPE_MAP.MICROBACHELORS:
return <>{programType}<sup>&reg;</sup> Program</>;
return (
<>
{programType}
<sup>&reg;</sup> Program
</>
);
case PROGRAM_TYPE_MAP.MASTERS:
return 'Master\'s';
return "Master's";
default:
return programType;
}
@@ -123,20 +131,29 @@ export const numberWithPrecision = (number, precision = 2) => number.toFixed(pre
// See https://openedx.atlassian.net/wiki/spaces/WS/pages/1045200922/Enroll+button+and+Course+Run+Selector+Logic
// for more detailed documentation on course run selection and the enroll button.
export function getActiveCourseRun(course) {
return course.courseRuns.find(courseRun => courseRun.uuid === course.advertisedCourseRunUuid);
return course.courseRuns.find(
(courseRun) => courseRun.uuid === course.advertisedCourseRunUuid,
);
}

export function getAvailableCourseRuns(course) {
return course.courseRuns.filter(
courseRun => courseRun.isMarketable && courseRun.isEnrollable && !isArchived(courseRun),
(courseRun) => courseRun.isMarketable && courseRun.isEnrollable && !isArchived(courseRun),
);
}

export function findCouponCodeForCourse(couponCodes, catalogList = []) {
return couponCodes.find((couponCode) => catalogList?.includes(couponCode.catalog) && hasValidStartExpirationDates({
startDate: couponCode.couponStartDate,
endDate: couponCode.couponEndDate,
}));
export function findCouponCodeForCourse(
couponCodes: CouponCode[],
catalogList: string[] = [],
) {
return couponCodes.find(
(couponCode) => catalogList?.includes(couponCode.catalog)
&& hasValidStartExpirationDates({
startDate: couponCode.couponStartDate,
endDate: couponCode.couponEndDate,
expirationDate: undefined,
}),
);
}

/**
@@ -154,58 +171,82 @@ export function findCouponCodeForCourse(couponCodes, catalogList = []) {
* the specified enterporise catalog uuids and course price.
*/
export const findEnterpriseOfferForCourse = ({
enterpriseOffers, catalogList = [], coursePrice,
enterpriseOffers,
catalogList = [],
coursePrice,
}: {
enterpriseOffers: EnterpriseOffer[];
catalogList: string[];
coursePrice?: number;
}) => {
if (!coursePrice) {
return undefined;
}

const applicableEnterpriseOffers = enterpriseOffers.filter((enterpriseOffer) => {
const {
remainingBalance,
remainingBalanceForUser,
} = enterpriseOffer;
const isCourseInCatalog = catalogList.includes(enterpriseOffer.enterpriseCatalogUuid);
if (!isCourseInCatalog) {
return false;
}
if (offerHasBookingsLimit(enterpriseOffer)) {
if (remainingBalance !== null && remainingBalance < coursePrice) {
const applicableEnterpriseOffers = enterpriseOffers.filter(
(enterpriseOffer) => {
const { remainingBalance, remainingBalanceForUser } = enterpriseOffer;
const isCourseInCatalog = catalogList.includes(
enterpriseOffer.enterpriseCatalogUuid,
);

if (!isCourseInCatalog) {
return false;
}

if (remainingBalanceForUser !== null && remainingBalanceForUser < coursePrice) {
return false;
if (offerHasBookingsLimit(enterpriseOffer)) {
if (
isNumber(remainingBalance)
&& (remainingBalance || 0) < coursePrice
) {
return false;
}

if (
isNumber(remainingBalanceForUser)
&& (remainingBalanceForUser || 0) < coursePrice
) {
return false;
}
}
}
return true;
});
return true;
},
);

// use offer that has no bookings limit
const enterpriseOfferWithoutBookingsLimit = applicableEnterpriseOffers.find(offer => !offerHasBookingsLimit(offer));
const enterpriseOfferWithoutBookingsLimit = applicableEnterpriseOffers.find(
(offer) => !offerHasBookingsLimit(offer),
);
if (enterpriseOfferWithoutBookingsLimit) {
return enterpriseOfferWithoutBookingsLimit;
}

// use offer that has largest remaining balance for user
const enterpriseOfferWithUserBookingsLimit = applicableEnterpriseOffers
.filter(offer => offer.remainingBalanceForUser)
.sort((a, b) => b.remainingBalanceForUser - a.remainingBalanceForUser)[0];
.filter((offer) => offer.remainingBalanceForUser)
.sort(
(a, b) => (b.remainingBalanceForUser || 0) - (a.remainingBalanceForUser || 0),
)[0];

if (enterpriseOfferWithUserBookingsLimit) {
return enterpriseOfferWithUserBookingsLimit;
}

// use offer with largest remaining balance overall
const enterpriseOfferWithBookingsLimit = applicableEnterpriseOffers
.sort((a, b) => b.remainingBalance - a.remainingBalance)[0];
const enterpriseOfferWithBookingsLimit = applicableEnterpriseOffers.sort(
(a, b) => (b.remainingBalance || 0) - (a.remainingBalance || 0),
)[0];

return enterpriseOfferWithBookingsLimit;
};

const getBestCourseMode = (courseModes) => {
const {
VERIFIED, PROFESSIONAL, NO_ID_PROFESSIONAL, AUDIT, HONOR,
VERIFIED,
PROFESSIONAL,
NO_ID_PROFESSIONAL,
AUDIT,
HONOR,
} = COURSE_MODES_MAP;
// Returns the 'highest' course mode available.
// Modes are ranked ['verified', 'professional', 'no-id-professional', 'audit', 'honor']
@@ -232,9 +273,9 @@ export function findHighestLevelSeatSku(seats) {
if (!seats || seats.length <= 0) {
return null;
}
const courseModes = seats.map(seat => seat.type);
const courseModes = seats.map((seat) => seat.type);
const courseMode = getBestCourseMode(courseModes);
return seats.find(seat => seat.type === courseMode)?.sku;
return seats.find((seat) => seat.type === courseMode)?.sku;
}

export function shouldUpgradeUserEnrollment({
@@ -248,15 +289,21 @@ export function shouldUpgradeUserEnrollment({

// Truncate a string to less than the maxLength characters without cutting the last word and append suffix at the end
export function shortenString(str, maxLength, suffix, separator = ' ') {
if (str.length <= maxLength) { return str; }
if (str.length <= maxLength) {
return str;
}
return `${str.substr(0, str.lastIndexOf(separator, maxLength))}${suffix}`;
}

export const getSubsidyToApplyForCourse = ({
applicableSubscriptionLicense = undefined,
applicableCouponCode = undefined,
applicableEnterpriseOffer = undefined,
}) => {
}: {
applicableSubscriptionLicense?: SubscriptionLicense | null;
applicableCouponCode?: CouponCode;
applicableEnterpriseOffer?: EnterpriseOffer;
}): EnterpriseSubsidy | null => {
if (applicableSubscriptionLicense) {
return {
...applicableSubscriptionLicense,
@@ -291,10 +338,12 @@ export const getSubsidyToApplyForCourse = ({

export const createEnrollFailureUrl = ({ courseRunKey, location }) => {
const baseQueryParams = new URLSearchParams(location.search);
baseQueryParams.set(ENROLLMENT_FAILED_QUERY_PARAM, true);
baseQueryParams.set(ENROLLMENT_FAILED_QUERY_PARAM, 'true');
baseQueryParams.set(ENROLLMENT_COURSE_RUN_KEY_QUERY_PARAM, courseRunKey);

return `${global.location.origin}${location.pathname}?${baseQueryParams.toString()}`;
return `${global.location.origin}${
location.pathname
}?${baseQueryParams.toString()}`;
};

export const createEnrollWithLicenseUrl = ({
@@ -316,7 +365,9 @@ export const createEnrollWithLicenseUrl = ({
left_sidebar_text_override: '',
source: 'enterprise-learner-portal',
});
return `${config.LMS_BASE_URL}/enterprise/grant_data_sharing_permissions/?${queryParams.toString()}`;
return `${
config.LMS_BASE_URL
}/enterprise/grant_data_sharing_permissions/?${queryParams.toString()}`;
};

export const createEnrollWithCouponCodeUrl = ({
@@ -334,8 +385,20 @@ export const createEnrollWithCouponCodeUrl = ({
sku,
code,
// Deliberately doubly encoded since it will get parsed on the redirect.
consent_url_param_string: `failure_url=${encodeURIComponent(failureUrl)}&left_sidebar_text_override=`,
consent_url_param_string: `failure_url=${encodeURIComponent(
failureUrl,
)}&left_sidebar_text_override=`,
});

return `${config.ECOMMERCE_BASE_URL}/coupons/redeem/?${queryParams.toString()}`;
return `${
config.ECOMMERCE_BASE_URL
}/coupons/redeem/?${queryParams.toString()}`;
};

export const courseUsesEntitlementPricing = (course) => {
const courseTypes = getConfig().COURSE_TYPES_WITH_ENTITLEMENT_LIST_PRICE;
if (courseTypes) {
return courseTypes.includes(course.courseType);
}
return false;
};
65 changes: 31 additions & 34 deletions src/components/course/enrollment/EnrollAction.jsx
Original file line number Diff line number Diff line change
@@ -36,40 +36,38 @@ const EnrollAction = ({
userEnrollment,
subscriptionLicense,
courseRunPrice,
triggerLicenseSubsidyEvent,
}) => {
switch (enrollmentType) {
case TO_COURSEWARE_PAGE: // scenario 1: already enrolled
return (
<ToCoursewarePage
enrollLabel={enrollLabel}
enrollmentUrl={enrollmentUrl}
userEnrollment={userEnrollment}
subscriptionLicense={subscriptionLicense}
/>
);
case VIEW_ON_DASHBOARD: // scenario 2: already enrolled
return <ViewOnDashboard enrollLabel={enrollLabel} />;
case ENROLL_DISABLED: // scenario 3 and 4: no enrollment possible
return <EnrollBtnDisabled enrollLabel={enrollLabel} />;
case TO_DATASHARING_CONSENT:
return (
<ToDataSharingConsentPage
enrollLabel={enrollLabel}
enrollmentUrl={enrollmentUrl}
triggerLicenseSubsidyEvent={triggerLicenseSubsidyEvent}
/>
);
case TO_ECOM_BASKET:
return (
<ToEcomBasketPage
enrollmentUrl={enrollmentUrl}
enrollLabel={enrollLabel}
courseRunPrice={courseRunPrice}
/>
);
case HIDE_BUTTON:
default: return null;
return (
<ToCoursewarePage
enrollLabel={enrollLabel}
enrollmentUrl={enrollmentUrl}
userEnrollment={userEnrollment}
subscriptionLicense={subscriptionLicense}
/>
);
case VIEW_ON_DASHBOARD: // scenario 2: already enrolled
return <ViewOnDashboard enrollLabel={enrollLabel} />;
case ENROLL_DISABLED: // scenario 3 and 4: no enrollment possible
return <EnrollBtnDisabled enrollLabel={enrollLabel} />;
case TO_DATASHARING_CONSENT:
return (
<ToDataSharingConsentPage
enrollLabel={enrollLabel}
enrollmentUrl={enrollmentUrl}
/>
);
case TO_ECOM_BASKET:
return (
<ToEcomBasketPage
enrollmentUrl={enrollmentUrl}
enrollLabel={enrollLabel}
courseRunPrice={courseRunPrice}
/>
);
case HIDE_BUTTON:
default: return null;
}
};

@@ -79,15 +77,14 @@ EnrollAction.propTypes = {
enrollmentUrl: PropTypes.string,
userEnrollment: PropTypes.shape({}),
subscriptionLicense: PropTypes.shape({}),
courseRunPrice: PropTypes.number.isRequired,
triggerLicenseSubsidyEvent: PropTypes.bool,
courseRunPrice: PropTypes.number,
};

EnrollAction.defaultProps = {
enrollmentUrl: null,
userEnrollment: null,
subscriptionLicense: null,
triggerLicenseSubsidyEvent: false,
courseRunPrice: 0,
};

export default EnrollAction;
Original file line number Diff line number Diff line change
@@ -6,15 +6,14 @@ import { Hyperlink } from '@edx/paragon';
import {
useOptimizelyEnrollmentClickHandler,
useTrackSearchConversionClickHandler,
useOptimizelyLicenseSubsidyEnrollmentClickHandler,
} from '../../data/hooks';
import { enrollLinkClass } from '../constants';
import { EnrollButtonCta } from '../common';
import { CourseContext } from '../../CourseContextProvider';
import { CourseEnrollmentsContext } from '../../../dashboard/main-content/course-enrollments/CourseEnrollmentsContextProvider';

// Data sharing consent
const ToDataSharingConsentPage = ({ enrollLabel, enrollmentUrl, triggerLicenseSubsidyEvent }) => {
const ToDataSharingConsentPage = ({ enrollLabel, enrollmentUrl }) => {
const {
state: {
activeCourseRun: { key: courseRunKey },
@@ -33,10 +32,6 @@ const ToDataSharingConsentPage = ({ enrollLabel, enrollmentUrl, triggerLicenseSu
courseRunKey,
courseEnrollmentsByStatus,
});
const optimizelyLicenseSubsidyHandler = useOptimizelyLicenseSubsidyEnrollmentClickHandler({
href: enrollmentUrl,
courseRunKey,
});

return (
<EnrollButtonCta
@@ -47,7 +42,6 @@ const ToDataSharingConsentPage = ({ enrollLabel, enrollmentUrl, triggerLicenseSu
onClick={(e) => {
analyticsHandler(e);
optimizelyHandler(e);
if (triggerLicenseSubsidyEvent) { optimizelyLicenseSubsidyHandler(e); }
}}
/>
);
@@ -56,11 +50,6 @@ const ToDataSharingConsentPage = ({ enrollLabel, enrollmentUrl, triggerLicenseSu
ToDataSharingConsentPage.propTypes = {
enrollLabel: PropTypes.node.isRequired,
enrollmentUrl: PropTypes.string.isRequired,
triggerLicenseSubsidyEvent: PropTypes.bool,
};

ToDataSharingConsentPage.defaultProps = {
triggerLicenseSubsidyEvent: false,
};

export default ToDataSharingConsentPage;
Original file line number Diff line number Diff line change
@@ -154,7 +154,6 @@ describe('scenarios user not yet enrolled, but eligible to enroll', () => {
enrollLabel={<EnrollLabel enrollLabelText={enrollLabelText} />}
enrollmentUrl={enrollmentUrl}
courseRunPrice={100}
triggerLicenseSubsidyEvent
/>
);
renderEnrollAction({ enrollAction });
12 changes: 12 additions & 0 deletions src/components/course/enrollment/utils.js
Original file line number Diff line number Diff line change
@@ -50,3 +50,15 @@ export function determineEnrollmentType({
// which takes care of redemption.
return TO_ECOM_BASKET;
}

/**
*
* @param {*} entitlements List of course entitlements
* @returns Price gleaned from entitlements
*/
export function getEntitlementPrice(entitlements) {
if (entitlements?.length) {
return Number(entitlements[0].price);
}
return undefined;
}
2 changes: 0 additions & 2 deletions src/components/course/tests/CourseRunCard.test.jsx
Original file line number Diff line number Diff line change
@@ -23,7 +23,6 @@ import { SubsidyRequestsContext } from '../../enterprise-subsidy-requests/Subsid
import * as subsidyRequestsHooks from '../data/hooks';
import { enrollButtonTypes } from '../enrollment/constants';
import * as utils from '../enrollment/utils';
import * as optimizelyUtils from '../../../utils/optimizely';

const COURSE_UUID = 'foo';
const COURSE_RUN_START = moment().format();
@@ -272,7 +271,6 @@ describe('<CourseRunCard/>', () => {

test('user see the struck out price message in the card', () => {
jest.spyOn(utils, 'determineEnrollmentType').mockImplementation(() => enrollButtonTypes.TO_DATASHARING_CONSENT);
jest.spyOn(optimizelyUtils, 'isExperimentVariant').mockImplementation(() => true);
subsidyRequestsHooks.useCoursePriceForUserSubsidy.mockReturnValueOnce([{ list: 100 }, CURRENCY_USD]);
const courseRunStart = moment(COURSE_RUN_START).add(1, 'd').format();
const courseRun = generateCourseRun({
2 changes: 1 addition & 1 deletion src/components/pathway-progress/data/service.js
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@ export function getPathwayProgressDetails(pathwayUUID) {
return getAuthenticatedHttpClient().get(url);
}

// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
export function getInProgressPathways(enterpriseUUID) {
// TODO: after adding support of filtering on enterprise UUID, send the uuid to endpoint as well
const config = getConfig();
228 changes: 110 additions & 118 deletions src/components/program-progress/ProgramProgressCourses.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import {
Form, Col, Row,
} from '@edx/paragon';
import { Form, Col, Row } from '@edx/paragon';
import moment from 'moment';
import { CheckCircle } from '@edx/paragon/icons';
import { AppContext } from '@edx/frontend-platform/react';
@@ -50,17 +48,22 @@ const ProgramProgressCourses = ({ courseData }) => {
if (courseData?.notStarted) {
coursesNotStarted = getNotStartedCourseDetails(courseData.notStarted);
}
const { courseWithMultipleCourseRun, courseWithSingleCourseRun } = coursesNotStarted;
const {
courseWithMultipleCourseRun,
courseWithSingleCourseRun,
} = coursesNotStarted;

const getCertificatePrice = (course) => {
const certificatePrice = getCertificatePriceString(course);
if (userHasLicenseOrCoupon) {
return (
<>
{certificatePrice
&& (
{certificatePrice && (
<del>
<span className="text-success-500 pr-1.5 pl-1.5"> {certificatePrice}</span>
<span className="text-success-500 pr-1.5 pl-1.5">
{' '}
{certificatePrice}
</span>
</del>
)}
{courseSponserdByEnterprise}
@@ -104,145 +107,134 @@ const ProgramProgressCourses = ({ courseData }) => {

return (
<div className="col-10 p-0">
{coursesInProgress?.length > 0
&& (
{coursesInProgress?.length > 0 && (
<div className="mb-5">
<h4 className="white-space-pre">COURSES IN PROGRESS {coursesInProgress.length}</h4>
<h4 className="white-space-pre">
COURSES IN PROGRESS {coursesInProgress.length}
</h4>
<hr />
<div className="courses">
{coursesInProgress.map((course) => (
(
<div className="mt-2.5 pt-2 pl-3 pb-5.5 pr-3" key={course.key}>
<h4 className="text-dark-500">{course.title}</h4>
<p className="text-gray-500 text-capitalize mt-1">Enrolled:
({course?.pacingType.replace('_', '-')}) Started {moment(course.start)
.format('MMMM Do, YYYY')}
</p>
<a
className="btn btn-outline-primary btn-xs-block float-right mb-4 mt-4"
href={courseAboutPageURL(course.key)}
>
{course.isEnded ? 'View Archived Course' : 'View Course'}
</a>
{course.certificateUrl
? renderCertificatePurchased()
: courseUpgradationAvailable(course)
<div className="mt-2.5 pt-2 pl-3 pb-5.5 pr-3" key={course.key}>
<h4 className="text-dark-500">{course.title}</h4>
<p className="text-gray-500 text-capitalize mt-1">
Enrolled: ({course?.pacingType.replace('_', '-')}) Started{' '}
{moment(course.start).format('MMMM Do, YYYY')}
</p>
<a
className="btn btn-outline-primary btn-xs-block float-right mb-4 mt-4"
href={courseAboutPageURL(course.key)}
>
{course.isEnded ? 'View Archived Course' : 'View Course'}
</a>
{course.certificateUrl
? renderCertificatePurchased()
: courseUpgradationAvailable(course)
&& renderCertificatePriceMessage(course)}
</div>
)
</div>
))}
</div>
</div>
)}
{courseData?.notStarted?.length > 0
&& (
{courseData?.notStarted?.length > 0 && (
<div className="mb-5 courses">
<h4 className="white-space-pre"> REMAINING COURSES {courseData?.notStarted?.length}</h4>
<h4 className="white-space-pre">
{' '}
REMAINING COURSES {courseData?.notStarted?.length}
</h4>
<hr />
{courseWithSingleCourseRun.map((course) => (
(
<div className="mt-4.5 pl-3 pb-5 pr-3" key={course.key}>
<h4 className="text-dark-500">{course.title}</h4>
{course.isEnrollable
? (
<>
<p className="text-gray-500 text-capitalize mt-1">
({course?.pacingType.replace('_', '-')}) Starts {moment(course.start)
.format('MMMM Do, YYYY')}
</p>
<a
className="btn btn-outline-primary btn-xs-block mt-2 float-right"
href={courseAboutPageURL(course.key)}
>
Enroll Now
</a>
</>
)
: (
<p
className=" mt-2 float-right"
>
{NotCurrentlyAvailable}
</p>
)}
</div>
)
<div className="mt-4.5 pl-3 pb-5 pr-3" key={course.key}>
<h4 className="text-dark-500">{course.title}</h4>
{course.isEnrollable ? (
<>
<p className="text-gray-500 text-capitalize mt-1">
({course?.pacingType.replace('_', '-')}) Starts{' '}
{moment(course.start).format('MMMM Do, YYYY')}
</p>
<a
className="btn btn-outline-primary btn-xs-block mt-2 float-right"
href={courseAboutPageURL(course.key)}
>
Enroll Now
</a>
</>
) : (
<p className=" mt-2 float-right">{NotCurrentlyAvailable}</p>
)}
</div>
))}

{courseWithMultipleCourseRun.map((course) => (
(
<div className="mt-4.5 pl-3 pb-5 pr-3" key={course.key}>
<h4 className="text-dark-500">{course.title}</h4>
{course.isEnrollable
? (
<>
<p className="text-gray-500 text-capitalize mt-1">
{course.courseRunDate?.length > 1
? (
<Form.Group className="pr-0" as={Col} controlId="formGridState-2">
<Form.Label>Your available sessions:</Form.Label>
<Form.Control as="select">
{course.courseRunDate.map(cRunDate => (
<option>{cRunDate}</option>
))}
</Form.Control>
</Form.Group>
)
: (
<span data-testid="course-run-single-date">
({course?.pacingType.replace('_', '-')}) Starts {moment(course.start)
.format('MMMM Do, YYYY')}
</span>
)}
</p>
<a
className="btn btn-outline-primary btn-xs-block mt-2 float-right"
href={courseAboutPageURL(course.key)}
<div className="mt-4.5 pl-3 pb-5 pr-3" key={course.key}>
<h4 className="text-dark-500">{course.title}</h4>
{course.isEnrollable ? (
<>
<p className="text-gray-500 text-capitalize mt-1">
{course.courseRunDate?.length > 1 ? (
<Form.Group
className="pr-0"
as={Col}
controlId="formGridState-2"
>
Learn More
</a>
</>
)
: (
<p className="mt-2 float-right">
{NotCurrentlyAvailable}
</p>
)}
</div>
)
<Form.Label>Your available sessions:</Form.Label>
<Form.Control as="select">
{course.courseRunDate.map((cRunDate) => (
<option>{cRunDate}</option>
))}
</Form.Control>
</Form.Group>
) : (
<span data-testid="course-run-single-date">
({course?.pacingType.replace('_', '-')}) Starts{' '}
{moment(course.start).format('MMMM Do, YYYY')}
</span>
)}
</p>
<a
className="btn btn-outline-primary btn-xs-block mt-2 float-right"
href={courseAboutPageURL(course.key)}
>
Learn More
</a>
</>
) : (
<p className="mt-2 float-right">{NotCurrentlyAvailable}</p>
)}
</div>
))}
</div>
)}
{coursesCompleted?.length > 0
&& (
{coursesCompleted?.length > 0 && (
<div className="mb-6 courses">
<h4 className="white-space-pre"> COURSES COMPLETED {coursesCompleted.length}</h4>
<h4 className="white-space-pre">
{' '}
COURSES COMPLETED {coursesCompleted.length}
</h4>
<hr />
{coursesCompleted.map((course) => (
(
<div className="mt-4.5 pl-3 pb-5 pr-3" key={course.key}>
<h4 className="text-dark-500">{course.title}</h4>
<p className="text-gray-500 text-capitalize mt-1">
({course?.pacingType.replace('_', '-')}) Started {moment(course.start)
.format('MMMM Do, YYYY')}
</p>
<a
className="btn btn-outline-primary btn-xs-block mb-6 float-right"
href={courseAboutPageURL(course.key)}
>
View Course
</a>
<div className="mt-4.5 pl-3 pb-5 pr-3" key={course.key}>
<h4 className="text-dark-500">{course.title}</h4>
<p className="text-gray-500 text-capitalize mt-1">
({course?.pacingType.replace('_', '-')}) Started{' '}
{moment(course.start).format('MMMM Do, YYYY')}
</p>
<a
className="btn btn-outline-primary btn-xs-block mb-6 float-right"
href={courseAboutPageURL(course.key)}
>
View Course
</a>

{course.certificateUrl ? renderCertificatePurchased()
: courseUpgradationAvailable(course)
{course.certificateUrl
? renderCertificatePurchased()
: courseUpgradationAvailable(course)
&& renderCertificatePriceMessage(course)}
</div>
)
</div>
))}
</div>
)}
</div>

);
};
ProgramProgressCourses.propTypes = {
19 changes: 2 additions & 17 deletions src/components/search/SearchCourseCard.jsx
Original file line number Diff line number Diff line change
@@ -10,8 +10,6 @@ import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils';

import { getPrimaryPartnerLogo, isDefinedAndNotNull } from '../../utils/common';
import { GENERAL_LENGTH_COURSE, SHORT_LENGTH_COURSE } from './data/constants';
import { useCourseAboutPageVisitClickHandler } from './data/hooks';
import { isExperimentVariant } from '../../utils/optimizely';
import { isShortCourse } from './utils';

const SearchCourseCard = ({ hit, isLoading, ...rest }) => {
@@ -57,27 +55,14 @@ const SearchCourseCard = ({ hit, isLoading, ...rest }) => {
[course],
);

const config = getConfig();
const isExperimentVariationA = isExperimentVariant(
config.EXPERIMENT_3_ID,
config.EXPERIMENT_3_VARIANT_1_ID,
);

const isShortLengthCourse = isShortCourse(course);

const primaryPartnerLogo = getPrimaryPartnerLogo(partnerDetails);

const handleCourseAboutPageVisitClick = useCourseAboutPageVisitClickHandler({
href: linkToCourse,
courseKey: course.key,
enterpriseId: uuid,
});

const handleCardClick = (e) => {
const handleCardClick = () => {
if (!linkToCourse) {
return;
}
handleCourseAboutPageVisitClick(e);
sendEnterpriseTrackEvent(
uuid,
'edx.ui.enterprise.learner_portal.search.card.clicked',
@@ -123,7 +108,7 @@ const SearchCourseCard = ({ hit, isLoading, ...rest }) => {
<Card.Section />
<Card.Footer textElement={(
<span className="text-muted">
{(isExperimentVariationA && isShortLengthCourse) ? SHORT_LENGTH_COURSE : GENERAL_LENGTH_COURSE}
{ isShortLengthCourse ? SHORT_LENGTH_COURSE : GENERAL_LENGTH_COURSE }
</span>
)}
/>
33 changes: 1 addition & 32 deletions src/components/search/data/hooks.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import {
useContext, useMemo, useEffect, useCallback,
} from 'react';
import { useContext, useMemo, useEffect } from 'react';
import {
SearchContext, getCatalogString, SHOW_ALL_NAME, setRefinementAction,
} from '@edx/frontend-enterprise-catalog-search';
import { features } from '../../../config';
import { LICENSE_STATUS } from '../../enterprise-user-subsidy/data/constants';
import { pushEvent, EVENTS } from '../../../utils/optimizely';

// How long to delay an event, so that we allow enough time for any async analytics event call to resolve
const CLICK_DELAY_MS = 300; // 300ms replicates Segment's ``trackLink`` function

export const useSearchCatalogs = ({
subscriptionPlan,
@@ -79,28 +73,3 @@ export const useDefaultSearchFilters = ({

return { filters };
};

/**
* Returns a function to be used as a click handler emitting an optimizely event on course about page visit click event.
*
* @returns Click handler function for course about page visit click events.
*/
export const useCourseAboutPageVisitClickHandler = ({ href, courseKey, enterpriseId }) => {
const handleClick = useCallback(
(e) => {
// If tracking is on a link with an external href destination, we must intentionally delay the default click
// behavior to allow enough time for the async analytics event call to resolve.
if (href) {
e.preventDefault();
setTimeout(() => {
global.location.href = href;
}, CLICK_DELAY_MS);
}
// Send the Optimizely event to track the course about page visit
pushEvent(EVENTS.COURSE_ABOUT_PAGE_CLICK, { courseKey, enterpriseId });
},
[href, courseKey, enterpriseId],
);

return handleClick;
};
27 changes: 0 additions & 27 deletions src/components/search/tests/SearchCourseCard.test.jsx
Original file line number Diff line number Diff line change
@@ -3,15 +3,12 @@ import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AppContext } from '@edx/frontend-platform/react';
import '@testing-library/jest-dom/extend-expect';
import { renderHook } from '@testing-library/react-hooks';

import SearchCourseCard from '../SearchCourseCard';
import * as optimizelyUtils from '../../../utils/optimizely';
import * as courseSearchUtils from '../utils';

import { renderWithRouter } from '../../../utils/tests';
import { TEST_ENTERPRISE_SLUG, TEST_IMAGE_URL } from './constants';
import { useCourseAboutPageVisitClickHandler } from '../data/hooks';

jest.mock('react-truncate', () => ({
__esModule: true,
@@ -89,7 +86,6 @@ describe('<SearchCourseCard />', () => {
});

test('render course_length field in place of course text', () => {
jest.spyOn(optimizelyUtils, 'isExperimentVariant').mockImplementation(() => true);
jest.spyOn(courseSearchUtils, 'isShortCourse').mockImplementation(() => true);

const { container } = renderWithRouter(<SearchCourseCardWithAppContext {...defaultProps} />);
@@ -99,34 +95,11 @@ describe('<SearchCourseCard />', () => {
});

test('do not render course_length field in place of course text', () => {
jest.spyOn(optimizelyUtils, 'isExperimentVariant').mockImplementation(() => true);
jest.spyOn(courseSearchUtils, 'isShortCourse').mockImplementation(() => false);

const { container } = renderWithRouter(<SearchCourseCardWithAppContext {...defaultProps} />);

// assert that the card footer shows text "Course"
expect(container.querySelector('.pgn__card-footer-text')).toHaveTextContent('Course');
});

test('optimizely event is being triggered in onClick when search card is clicked', () => {
const basicProps = {
courseKey: 'course-key',
enterpriseId: 'enterprise-id',
};
const pushEventSpy = jest.spyOn(optimizelyUtils, 'pushEvent').mockImplementation(() => (true));

const { result } = renderHook(() => useCourseAboutPageVisitClickHandler(basicProps));
result.current({ preventDefault: jest.fn() });

const { container } = renderWithRouter(<SearchCourseCardWithAppContext {...defaultProps} />);

// select card with class pgn__card and click on it
const card = container.querySelector('.pgn__card');
card.click();

expect(pushEventSpy).toHaveBeenCalledWith('enterprise_learner_portal_course_about_page_click', {
courseKey: 'course-key',
enterpriseId: 'enterprise-id',
});
});
});
2 changes: 1 addition & 1 deletion src/components/site-header/menu/Menu.jsx
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ MenuTrigger.defaultProps = {
tag: 'div',
className: null,
};
const MenuTriggerType = <MenuTrigger />.type;
const MenuTriggerType = (<MenuTrigger />).type;

const MenuContent = ({ tag, className, ...attributes }) => React.createElement(tag, {
className: ['menu-content', className].join(' '),
Original file line number Diff line number Diff line change
@@ -3,33 +3,44 @@ import { Badge, Card, Stack } from '@edx/paragon';
import { useHistory } from 'react-router-dom';
import Truncate from 'react-truncate';
import { AppContext } from '@edx/frontend-platform/react';
import PropTypes from 'prop-types';
import getCommonSkills, { linkToCourse } from './data/utils';
import { shortenString } from '../course/data/utils';
import { ELLIPSIS_STR } from '../course/data/constants';
import { isDefinedAndNotNull } from '../../utils/common';
import { MAX_VISIBLE_SKILLS_COURSE, SKILL_NAME_CUTOFF_LIMIT } from './constants';
import {
MAX_VISIBLE_SKILLS_COURSE,
SKILL_NAME_CUTOFF_LIMIT,
} from './constants';
import { Course } from '../course/data/types';
import { ReactAppContext } from '../../../external';

const CourseCard = ({
isLoading, course, allSkills,
}) => {
type CourseCardProps = {
course: Course;
allSkills: string[];
isLoading: boolean;
};

const CourseCard = ({ isLoading, course, allSkills }: CourseCardProps) => {
const history = useHistory();
const { enterpriseConfig } = useContext(AppContext);
const { enterpriseConfig } = useContext<ReactAppContext>(AppContext);
const { slug, uuid } = enterpriseConfig;
const partnerDetails = useMemo(() => {
if (!Object.keys(course).length || !isDefinedAndNotNull(course.partners)) {
return {};
}
return {
primaryPartner: course.partners.length > 0 ? course.partners[0] : undefined,
primaryPartner:
course.partners.length > 0 ? course.partners[0] : undefined,
showPartnerLogo: course.partners.length === 1,
};
}, [course]);

const primaryPartnerLogo = partnerDetails.primaryPartner && partnerDetails.showPartnerLogo ? {
src: partnerDetails.primaryPartner.logoImageUrl,
alt: partnerDetails.primaryPartner.name,
} : undefined;
const primaryPartnerLogo = partnerDetails.primaryPartner && partnerDetails.showPartnerLogo
? {
src: partnerDetails.primaryPartner.logoImageUrl,
alt: partnerDetails.primaryPartner.name,
}
: undefined;

const handleCardClick = () => {
if (isLoading) {
@@ -60,46 +71,28 @@ const CourseCard = ({
{course.title}
</Truncate>
)}
subtitle={course.partners.length > 0 && (
<Truncate lines={2} trimWhitespace>
{course.partners
.map((partner) => partner.name)
.join(', ')}
</Truncate>
)}
subtitle={
course.partners.length > 0 && (
<Truncate lines={2} trimWhitespace>
{course.partners.map((partner) => partner.name).join(', ')}
</Truncate>
)
}
/>
<Card.Section>
<Stack direction="horizontal" gap={2} className="flex-wrap">
{course.skillNames?.length > 0 && getCommonSkills(
course,
allSkills,
MAX_VISIBLE_SKILLS_COURSE,
).map((skill) => (
<Badge key={skill} variant="light">
{shortenString(
skill,
SKILL_NAME_CUTOFF_LIMIT,
ELLIPSIS_STR,
)}
</Badge>
))}
{course.skillNames?.length > 0
&& getCommonSkills(course, allSkills, MAX_VISIBLE_SKILLS_COURSE).map(
(skill) => (
<Badge key={skill} variant="light">
{shortenString(skill, SKILL_NAME_CUTOFF_LIMIT, ELLIPSIS_STR)}
</Badge>
),
)}
</Stack>
</Card.Section>
</Card>
);
};

CourseCard.propTypes = {
course: PropTypes.shape({
title: PropTypes.string.isRequired,
cardImageUrl: PropTypes.string.isRequired,
originalImageUrl: PropTypes.string,
key: PropTypes.string.isRequired,
partners: PropTypes.arrayOf(PropTypes.shape()).isRequired,
skillNames: PropTypes.arrayOf(PropTypes.string).isRequired,
}).isRequired,
allSkills: PropTypes.arrayOf(PropTypes.string).isRequired,
isLoading: PropTypes.bool.isRequired,
};

export default CourseCard;
2 changes: 1 addition & 1 deletion src/components/skills-quiz/SearchCurrentJobCard.jsx
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@ const SearchCurrentJobCard = ({ index }) => {
useEffect(
() => {
let fetch = true;
fetchJobs(); // eslint-disable-line no-use-before-define
fetchJobs(); // eslint-disable-line @typescript-eslint/no-use-before-define
return () => { fetch = false; };

async function fetchJobs() {
4 changes: 2 additions & 2 deletions src/components/skills-quiz/SearchJobCard.jsx
Original file line number Diff line number Diff line change
@@ -31,7 +31,7 @@ const SearchJobCard = ({ index }) => {
useEffect(
() => {
let fetch = true;
fetchJobs(); // eslint-disable-line no-use-before-define
fetchJobs(); // eslint-disable-line @typescript-eslint/no-use-before-define
return () => { fetch = false; };

async function fetchJobs() {
@@ -52,7 +52,7 @@ const SearchJobCard = ({ index }) => {
useEffect(() => {
let fetch = true;
if (currentJob) {
fetchJob(); // eslint-disable-line no-use-before-define
fetchJob(); // eslint-disable-line @typescript-eslint/no-use-before-define
}
return () => { fetch = false; };
async function fetchJob() {
1 change: 1 addition & 0 deletions src/components/skills-quiz/SearchProgramCard.jsx
Original file line number Diff line number Diff line change
@@ -76,6 +76,7 @@ const SearchProgramCard = ({ index }) => {
useEffect(
() => {
let fetch = true;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
fetchPrograms(); // eslint-disable-line no-use-before-define
return () => { fetch = false; };

4 changes: 2 additions & 2 deletions src/components/skills-quiz/SkillsContextProvider.jsx
Original file line number Diff line number Diff line change
@@ -14,8 +14,8 @@ const reducer = (state, action) => {
delete nextState.page;
nextState[action.key] = action.value;
return nextState;
default:
return state;
default:
return state;
}
};

32 changes: 16 additions & 16 deletions src/components/utils/search.js
Original file line number Diff line number Diff line change
@@ -11,27 +11,27 @@ import SearchPathwayCard from '../pathway/SearchPathwayCard';

export const getContentTypeFromTitle = (title) => {
switch (title) {
case PROGRAM_TITLE:
return CONTENT_TYPE_PROGRAM;
case COURSE_TITLE:
return CONTENT_TYPE_COURSE;
case PATHWAY_TITLE:
return CONTENT_TYPE_PATHWAY;
default:
return null;
case PROGRAM_TITLE:
return CONTENT_TYPE_PROGRAM;
case COURSE_TITLE:
return CONTENT_TYPE_COURSE;
case PATHWAY_TITLE:
return CONTENT_TYPE_PATHWAY;
default:
return null;
}
};

export const getHitComponentFromTitle = (title) => {
switch (title) {
case COURSE_TITLE:
return SearchCourseCard;
case PROGRAM_TITLE:
return SearchProgramCard;
case PATHWAY_TITLE:
return SearchPathwayCard;
default:
return null;
case COURSE_TITLE:
return SearchCourseCard;
case PROGRAM_TITLE:
return SearchProgramCard;
case PATHWAY_TITLE:
return SearchPathwayCard;
default:
return null;
}
};

4 changes: 0 additions & 4 deletions src/index.jsx
Original file line number Diff line number Diff line change
@@ -45,10 +45,6 @@ initialize({
LEARNER_SUPPORT_URL: process.env.LEARNER_SUPPORT_URL || null,
GETSMARTER_STUDENT_TC_URL: process.env.GETSMARTER_STUDENT_TC_URL || null,
GETSMARTER_PRIVACY_POLICY_URL: process.env.GETSMARTER_PRIVACY_POLICY_URL || null,
EXPERIMENT_2_ID: process.env.EXPERIMENT_2_ID || null,
EXPERIMENT_2_VARIANT_1_ID: process.env.EXPERIMENT_2_VARIANT_1_ID || null,
EXPERIMENT_3_ID: process.env.EXPERIMENT_3_ID || null,
EXPERIMENT_3_VARIANT_1_ID: process.env.EXPERIMENT_3_VARIANT_1_ID || null,
FEATURE_CONTENT_HIGHLIGHTS: process.env.FEATURE_CONTENT_HIGHLIGHTS || null,
EXPERIMENT_4_ID: process.env.EXPERIMENT_4_ID || null,
EXPERIMENT_4_VARIANT_1_ID: process.env.EXPERIMENT_4_VARIANT_1_ID || null,
1 change: 1 addition & 0 deletions src/utils/common.js
Original file line number Diff line number Diff line change
@@ -53,6 +53,7 @@ export const loginRefresh = async () => {
const loginRefreshUrl = `${config.LMS_BASE_URL}/login_refresh`;

try {
// eslint-disable-next-line @typescript-eslint/return-await
return await getAuthenticatedHttpClient().post(loginRefreshUrl);
} catch (error) {
const isUserUnauthenticated = error.response?.status === 401;
2 changes: 0 additions & 2 deletions src/utils/optimizely.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
export const EVENTS = {
ENROLLMENT_CLICK: 'enterprise_learner_portal_enrollment_click',
FIRST_ENROLLMENT_CLICK: 'enterprise_learner_portal_first_enrollment_click',
LICENSE_SUBSIDY_ENROLLMENT_CLICK: 'enterprise_learner_portal_license_subsidy_enrollment_click',
COURSE_ABOUT_PAGE_CLICK: 'enterprise_learner_portal_course_about_page_click',
};

export const getActiveExperiments = () => {
9 changes: 9 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "@edx/typescript-config",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist"
},
"include": ["src/**/*", "__mocks__/**/*", "*.d.ts", "*.eslintrc.js", "*.config.js"],
"exclude": ["dist", "src/icons/*"],
}

0 comments on commit c9e8c26

Please sign in to comment.