├── .dockerignore ├── .env ├── .env.development ├── .env.test ├── .eslintignore ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ ├── add-depr-ticket-to-depr-board.yml │ ├── add-issue-to-btr-project.yml │ ├── add-remove-label-on-comment.yml │ ├── commitlint.yml │ ├── lockfileversion-check.yml │ ├── self-assign-issue.yml │ ├── update-browserslist-db.yml │ └── validate.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── LICENSE ├── Makefile ├── README.rst ├── catalog-info.yaml ├── codecov.yml ├── docs └── decisions │ ├── 0001-record-architecture-decisions.rst │ ├── 0002-courseware-page-decisions.md │ ├── 0003-course-home-decisions.md │ ├── 0004-model-store.md │ ├── 0005-components-own-their-loading-state.md │ ├── 0006-thunk-and-api-naming.md │ ├── 0007-testing.md │ ├── 0008-liberal-courseware-path-handling.md │ └── 0009-courseware-api-direction.md ├── example.env.config.jsx ├── global-setup.js ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── index.html └── static │ └── LmsHtmlFragment.css ├── renovate.json ├── src ├── __snapshots__ │ └── index.test.jsx.snap ├── alerts │ ├── access-expiration-alert │ │ ├── AccessExpirationAlert.jsx │ │ ├── AccessExpirationMasqueradeBanner.jsx │ │ ├── hooks.js │ │ ├── index.js │ │ └── messages.ts │ ├── active-enteprise-alert │ │ ├── ActiveEnterpriseAlert.jsx │ │ ├── ActiveEnterpriseAlert.test.jsx │ │ ├── hooks.js │ │ ├── index.js │ │ └── messages.ts │ ├── course-start-alert │ │ ├── CourseStartAlert.jsx │ │ ├── CourseStartMasqueradeBanner.jsx │ │ ├── hooks.js │ │ └── index.js │ ├── enrollment-alert │ │ ├── EnrollmentAlert.jsx │ │ ├── clickHook.js │ │ ├── data │ │ │ └── api.js │ │ ├── hooks.js │ │ ├── index.js │ │ └── messages.ts │ ├── logistration-alert │ │ ├── AccountActivationAlert.jsx │ │ ├── LogistrationAlert.jsx │ │ ├── hooks.js │ │ ├── index.js │ │ └── messages.ts │ └── sequence-alerts │ │ ├── hooks.js │ │ └── messages.ts ├── constants.ts ├── course-home │ ├── courseware-search │ │ ├── CoursewareResultsFilter.jsx │ │ ├── CoursewareResultsFilter.test.jsx │ │ ├── CoursewareSearch.jsx │ │ ├── CoursewareSearch.test.jsx │ │ ├── CoursewareSearchEmpty.jsx │ │ ├── CoursewareSearchEmpty.test.jsx │ │ ├── CoursewareSearchForm.jsx │ │ ├── CoursewareSearchForm.test.jsx │ │ ├── CoursewareSearchResults.jsx │ │ ├── CoursewareSearchResults.test.jsx │ │ ├── CoursewareSearchToggle.jsx │ │ ├── CoursewareSearchToggle.test.jsx │ │ ├── __snapshots__ │ │ │ ├── CoursewareSearchEmpty.test.jsx.snap │ │ │ ├── CoursewareSearchResults.test.jsx.snap │ │ │ └── map-search-response.test.js.snap │ │ ├── courseware-search.scss │ │ ├── hooks.js │ │ ├── hooks.test.jsx │ │ ├── index.js │ │ ├── map-search-response.js │ │ ├── map-search-response.test.js │ │ ├── messages.ts │ │ └── test-data │ │ │ ├── mocked-response.json │ │ │ └── search-results-factory.js │ ├── data │ │ ├── __factories__ │ │ │ ├── courseHomeMetadata.factory.js │ │ │ ├── datesTabData.factory.js │ │ │ ├── index.js │ │ │ ├── outlineTabData.factory.js │ │ │ ├── progressTabData.factory.js │ │ │ └── upgradeNotificationData.factory.js │ │ ├── __snapshots__ │ │ │ └── redux.test.js.snap │ │ ├── api.js │ │ ├── api.test.js │ │ ├── index.js │ │ ├── pact-tests │ │ │ └── lmsPact.test.jsx │ │ ├── redux.test.js │ │ ├── slice.js │ │ └── thunks.js │ ├── dates-tab │ │ ├── DatesTab.jsx │ │ ├── DatesTab.test.jsx │ │ ├── index.jsx │ │ ├── messages.ts │ │ ├── timeline │ │ │ ├── Day.jsx │ │ │ ├── Day.scss │ │ │ ├── Timeline.jsx │ │ │ └── badgelist.jsx │ │ └── utils.jsx │ ├── discussion-tab │ │ ├── DiscussionTab.jsx │ │ └── DiscussionTab.test.jsx │ ├── goal-unsubscribe │ │ ├── GoalUnsubscribe.jsx │ │ ├── GoalUnsubscribe.test.jsx │ │ ├── ResultPage.jsx │ │ ├── index.jsx │ │ ├── messages.ts │ │ └── unsubscribe.svg │ ├── live-tab │ │ └── LiveTab.jsx │ ├── outline-tab │ │ ├── DateSummary.jsx │ │ ├── DateSummary.scss │ │ ├── LmsHtmlFragment.jsx │ │ ├── OutlineTab.jsx │ │ ├── OutlineTab.test.jsx │ │ ├── alerts │ │ │ ├── certificate-status-alert │ │ │ │ ├── CertificateStatusAlert.jsx │ │ │ │ ├── hooks.js │ │ │ │ ├── index.js │ │ │ │ └── messages.ts │ │ │ ├── course-end-alert │ │ │ │ ├── CourseEndAlert.jsx │ │ │ │ ├── hooks.js │ │ │ │ └── index.js │ │ │ ├── private-course-alert │ │ │ │ ├── PrivateCourseAlert.jsx │ │ │ │ ├── hooks.js │ │ │ │ ├── index.js │ │ │ │ └── messages.ts │ │ │ └── scheduled-content-alert │ │ │ │ ├── ScheduledCotentAlert.jsx │ │ │ │ ├── hooks.js │ │ │ │ └── index.js │ │ ├── index.js │ │ ├── messages.ts │ │ ├── section-outline │ │ │ ├── HiddenSequenceLink.tsx │ │ │ ├── Section.tsx │ │ │ ├── SectionTitle.tsx │ │ │ ├── SequenceDueDate.tsx │ │ │ ├── SequenceLink.tsx │ │ │ └── SequenceTitle.tsx │ │ └── widgets │ │ │ ├── CourseDates.jsx │ │ │ ├── CourseHandouts.jsx │ │ │ ├── CourseTools.jsx │ │ │ ├── FlagButton.jsx │ │ │ ├── FlagButton.scss │ │ │ ├── LearningGoalButton.jsx │ │ │ ├── ProctoringInfoPanel.jsx │ │ │ ├── ProctoringInfoPanel.scss │ │ │ ├── StartOrResumeCourseCard.jsx │ │ │ ├── WeeklyLearningGoalCard.jsx │ │ │ ├── WelcomeMessage.jsx │ │ │ ├── flag_black.svg │ │ │ ├── flag_gray.svg │ │ │ └── flag_outline.svg │ ├── progress-tab │ │ ├── ProgressHeader.jsx │ │ ├── ProgressTab.jsx │ │ ├── ProgressTab.test.jsx │ │ ├── certificate-status │ │ │ ├── CertificateStatus.jsx │ │ │ └── messages.ts │ │ ├── course-completion │ │ │ ├── CompleteDonutSegment.jsx │ │ │ ├── CompletionDonutChart.jsx │ │ │ ├── CompletionDonutChart.scss │ │ │ ├── CourseCompletion.jsx │ │ │ ├── IncompleteDonutSegment.jsx │ │ │ ├── LockedDonutSegment.jsx │ │ │ └── messages.ts │ │ ├── credit-information │ │ │ ├── CreditInformation.jsx │ │ │ └── messages.ts │ │ ├── grades │ │ │ ├── course-grade │ │ │ │ ├── CourseGrade.jsx │ │ │ │ ├── CourseGradeFooter.jsx │ │ │ │ ├── CourseGradeHeader.jsx │ │ │ │ ├── CurrentGradeTooltip.jsx │ │ │ │ ├── GradeBar.jsx │ │ │ │ ├── GradeBar.scss │ │ │ │ ├── GradeRangeTooltip.jsx │ │ │ │ └── PassingGradeTooltip.jsx │ │ │ ├── detailed-grades │ │ │ │ ├── DetailedGrades.jsx │ │ │ │ ├── DetailedGradesTable.jsx │ │ │ │ ├── ProblemScoreDrawer.jsx │ │ │ │ └── SubsectionTitleCell.jsx │ │ │ ├── grade-summary │ │ │ │ ├── AssignmentTypeCell.jsx │ │ │ │ ├── DroppableAssignmentFootnote.jsx │ │ │ │ ├── GradeSummary.jsx │ │ │ │ ├── GradeSummaryHeader.jsx │ │ │ │ ├── GradeSummaryTable.jsx │ │ │ │ └── GradeSummaryTableFooter.jsx │ │ │ └── messages.ts │ │ ├── messages.ts │ │ ├── related-links │ │ │ ├── RelatedLinks.jsx │ │ │ └── messages.ts │ │ └── utils.ts │ └── suggested-schedule-messaging │ │ ├── ShiftDatesAlert.jsx │ │ ├── SuggestedScheduleHeader.jsx │ │ ├── UpgradeToCompleteAlert.jsx │ │ ├── UpgradeToShiftDatesAlert.jsx │ │ ├── index.js │ │ └── messages.ts ├── course-tabs │ ├── CourseTabsNavigation.jsx │ ├── CourseTabsNavigation.test.jsx │ ├── course-tabs-navigation.scss │ ├── index.js │ └── messages.ts ├── courseware │ ├── CoursewareContainer.jsx │ ├── CoursewareContainer.test.jsx │ ├── CoursewareRedirectLandingPage.jsx │ ├── CoursewareRedirectLandingPage.test.jsx │ ├── RedirectPage.test.jsx │ ├── RedirectPage.tsx │ ├── course │ │ ├── Course.jsx │ │ ├── Course.test.jsx │ │ ├── JumpNavMenuItem.jsx │ │ ├── JumpNavMenuItem.test.jsx │ │ ├── bookmark │ │ │ ├── BookmarkButton.jsx │ │ │ ├── BookmarkButton.test.jsx │ │ │ ├── data │ │ │ │ ├── api.js │ │ │ │ ├── redux.test.js │ │ │ │ └── thunks.js │ │ │ └── index.js │ │ ├── breadcrumbs │ │ │ ├── BreadcrumbItem.tsx │ │ │ ├── CourseBreadcrumbs.jsx │ │ │ ├── CourseBreadcrumbs.test.jsx │ │ │ └── index.js │ │ ├── celebration │ │ │ ├── CelebrationModal.jsx │ │ │ ├── CelebrationModal.scss │ │ │ ├── WeeklyGoalCelebrationModal.jsx │ │ │ ├── assets │ │ │ │ ├── claps_280x201.gif │ │ │ │ ├── claps_456x328.gif │ │ │ │ └── target.svg │ │ │ ├── data │ │ │ │ └── api.js │ │ │ ├── index.js │ │ │ ├── messages.ts │ │ │ ├── utils.jsx │ │ │ └── utils.test.jsx │ │ ├── chat │ │ │ ├── Chat.jsx │ │ │ ├── Chat.test.jsx │ │ │ └── index.js │ │ ├── content-tools │ │ │ ├── ContentTools.jsx │ │ │ ├── ContentTools.test.jsx │ │ │ ├── calculator │ │ │ │ ├── Calculator.jsx │ │ │ │ ├── Calculator.test.jsx │ │ │ │ ├── calculator.scss │ │ │ │ ├── index.js │ │ │ │ └── messages.ts │ │ │ ├── contentTools.scss │ │ │ ├── index.js │ │ │ └── notes-visibility │ │ │ │ ├── NotesVisibility.jsx │ │ │ │ ├── NotesVisibility.test.jsx │ │ │ │ ├── index.js │ │ │ │ └── messages.ts │ │ ├── course-exit │ │ │ ├── CatalogSuggestion.jsx │ │ │ ├── CourseCelebration.jsx │ │ │ ├── CourseExit.jsx │ │ │ ├── CourseExit.test.jsx │ │ │ ├── CourseInProgress.jsx │ │ │ ├── CourseNonPassing.jsx │ │ │ ├── CourseRecommendations.jsx │ │ │ ├── CourseRecommendations.scss │ │ │ ├── DashboardFootnote.jsx │ │ │ ├── Footnote.jsx │ │ │ ├── ProgramCompletion.jsx │ │ │ ├── UpgradeFootnote.jsx │ │ │ ├── assets │ │ │ │ ├── celebration_456x328.gif │ │ │ │ └── celebration_750x540.gif │ │ │ ├── data │ │ │ │ ├── api.js │ │ │ │ ├── slice.js │ │ │ │ └── thunks.js │ │ │ ├── index.js │ │ │ ├── messages.ts │ │ │ └── utils.js │ │ ├── course-license │ │ │ ├── CourseLicense.jsx │ │ │ ├── index.js │ │ │ └── messages.ts │ │ ├── index.js │ │ ├── messages.ts │ │ ├── new-sidebar │ │ │ ├── Sidebar.tsx │ │ │ ├── SidebarContext.ts │ │ │ ├── SidebarContextProvider.tsx │ │ │ ├── SidebarTriggers.tsx │ │ │ ├── common │ │ │ │ └── SidebarBase.tsx │ │ │ ├── icons │ │ │ │ ├── RightSidebarFilled.tsx │ │ │ │ ├── RightSidebarOutlined.tsx │ │ │ │ └── index.ts │ │ │ ├── messages.ts │ │ │ └── sidebars │ │ │ │ ├── discussions-notifications │ │ │ │ ├── DiscussionsNotificationsSidebar.tsx │ │ │ │ ├── DiscussionsNotificationsTrigger.tsx │ │ │ │ ├── discussions │ │ │ │ │ ├── DiscussionsWidget.test.tsx │ │ │ │ │ └── DiscussionsWidget.tsx │ │ │ │ ├── index.ts │ │ │ │ └── notifications │ │ │ │ │ ├── NotificationsWidget.test.tsx │ │ │ │ │ └── NotificationsWidget.tsx │ │ │ │ └── index.ts │ │ ├── sequence │ │ │ ├── Sequence.jsx │ │ │ ├── Sequence.test.jsx │ │ │ ├── SequenceContent.jsx │ │ │ ├── SequenceContent.test.jsx │ │ │ ├── Unit │ │ │ │ ├── ContentIFrame.jsx │ │ │ │ ├── ContentIFrame.test.jsx │ │ │ │ ├── UnitSuspense.jsx │ │ │ │ ├── UnitSuspense.test.jsx │ │ │ │ ├── constants.js │ │ │ │ ├── hooks │ │ │ │ │ ├── index.js │ │ │ │ │ ├── useExamAccess.js │ │ │ │ │ ├── useExamAccess.test.jsx │ │ │ │ │ ├── useIFrameBehavior.js │ │ │ │ │ ├── useIFrameBehavior.test.js │ │ │ │ │ ├── useLoadBearingHook.js │ │ │ │ │ ├── useLoadBearingHook.test.js │ │ │ │ │ ├── useModalIFrameData.js │ │ │ │ │ ├── useModalIFrameData.test.js │ │ │ │ │ ├── useShouldDisplayHonorCode.js │ │ │ │ │ └── useShouldDisplayHonorCode.test.js │ │ │ │ ├── index.jsx │ │ │ │ ├── index.test.jsx │ │ │ │ ├── urls.test.ts │ │ │ │ └── urls.ts │ │ │ ├── content-lock │ │ │ │ ├── ContentLock.jsx │ │ │ │ ├── ContentLock.test.jsx │ │ │ │ ├── index.js │ │ │ │ └── messages.ts │ │ │ ├── hidden-after-due │ │ │ │ ├── HiddenAfterDue.jsx │ │ │ │ ├── index.js │ │ │ │ └── messages.ts │ │ │ ├── honor-code │ │ │ │ ├── HonorCode.jsx │ │ │ │ ├── HonorCode.test.jsx │ │ │ │ ├── index.js │ │ │ │ └── messages.ts │ │ │ ├── index.js │ │ │ ├── lock-paywall │ │ │ │ ├── LockPaywall.jsx │ │ │ │ ├── LockPaywall.scss │ │ │ │ ├── LockPaywall.test.jsx │ │ │ │ ├── index.js │ │ │ │ └── messages.ts │ │ │ ├── messages.ts │ │ │ └── sequence-navigation │ │ │ │ ├── CompleteIcon.jsx │ │ │ │ ├── SequenceNavigation.jsx │ │ │ │ ├── SequenceNavigation.test.jsx │ │ │ │ ├── SequenceNavigationDropdown.jsx │ │ │ │ ├── SequenceNavigationDropdown.test.jsx │ │ │ │ ├── SequenceNavigationTabs.jsx │ │ │ │ ├── SequenceNavigationTabs.test.jsx │ │ │ │ ├── UnitButton.jsx │ │ │ │ ├── UnitButton.test.jsx │ │ │ │ ├── UnitIcon.jsx │ │ │ │ ├── UnitIcon.test.jsx │ │ │ │ ├── UnitNavigation.jsx │ │ │ │ ├── UnitNavigation.test.jsx │ │ │ │ ├── UnitNavigationEffortEstimate.jsx │ │ │ │ ├── generic │ │ │ │ ├── NextButton.jsx │ │ │ │ └── PreviousButton.jsx │ │ │ │ ├── hooks.js │ │ │ │ ├── index.js │ │ │ │ └── messages.ts │ │ ├── sidebar │ │ │ ├── Sidebar.jsx │ │ │ ├── SidebarContext.js │ │ │ ├── SidebarContextProvider.jsx │ │ │ ├── SidebarTriggers.jsx │ │ │ ├── common │ │ │ │ ├── SidebarBase.jsx │ │ │ │ ├── SidebarBase.scss │ │ │ │ └── TriggerBase.jsx │ │ │ └── sidebars │ │ │ │ ├── course-outline │ │ │ │ ├── CourseOutlineTray.jsx │ │ │ │ ├── CourseOutlineTray.scss │ │ │ │ ├── CourseOutlineTray.test.jsx │ │ │ │ ├── CourseOutlineTrigger.jsx │ │ │ │ ├── CourseOutlineTrigger.test.jsx │ │ │ │ ├── components │ │ │ │ │ ├── CompletionIcon.jsx │ │ │ │ │ ├── CompletionIcon.test.jsx │ │ │ │ │ ├── SidebarSection.jsx │ │ │ │ │ ├── SidebarSection.test.jsx │ │ │ │ │ ├── SidebarSequence.jsx │ │ │ │ │ ├── SidebarSequence.test.jsx │ │ │ │ │ ├── SidebarUnit.jsx │ │ │ │ │ ├── SidebarUnit.test.jsx │ │ │ │ │ ├── UnitIcon.jsx │ │ │ │ │ ├── UnitIcon.test.jsx │ │ │ │ │ └── UnitLinkWrapper.tsx │ │ │ │ ├── constants.js │ │ │ │ ├── hooks.js │ │ │ │ ├── icons │ │ │ │ │ ├── DashedCircleIcon.jsx │ │ │ │ │ └── index.js │ │ │ │ ├── index.js │ │ │ │ └── messages.ts │ │ │ │ ├── discussions │ │ │ │ ├── Discussions.scss │ │ │ │ ├── DiscussionsSidebar.jsx │ │ │ │ ├── DiscussionsSidebar.test.jsx │ │ │ │ ├── DiscussionsTrigger.jsx │ │ │ │ ├── DiscussionsTrigger.test.jsx │ │ │ │ ├── index.js │ │ │ │ └── messages.ts │ │ │ │ ├── index.js │ │ │ │ └── notifications │ │ │ │ ├── NotificationIcon.jsx │ │ │ │ ├── NotificationIcon.scss │ │ │ │ ├── NotificationTray.jsx │ │ │ │ ├── NotificationTray.test.jsx │ │ │ │ ├── NotificationTrigger.jsx │ │ │ │ ├── NotificationTrigger.test.jsx │ │ │ │ └── index.js │ │ └── test-utils.jsx │ ├── data │ │ ├── __factories__ │ │ │ ├── courseMetadata.factory.js │ │ │ ├── courseRecommendations.factory.js │ │ │ ├── discussionTopics.factory.js │ │ │ ├── index.js │ │ │ ├── learningSequencesOutline.factory.js │ │ │ └── sequenceMetadata.factory.js │ │ ├── api.js │ │ ├── index.js │ │ ├── pact-tests │ │ │ └── lmsPact.test.jsx │ │ ├── redux.test.js │ │ ├── selectors.js │ │ ├── slice.js │ │ ├── thunks.js │ │ └── utils.js │ ├── index.js │ ├── messages.ts │ ├── social-share │ │ ├── SocialIcons.jsx │ │ └── messages.ts │ └── utils.jsx ├── data │ ├── hooks.ts │ ├── localStorage.js │ └── sessionStorage.js ├── decode-page-route │ ├── __snapshots__ │ │ └── index.test.jsx.snap │ ├── index.jsx │ └── index.test.jsx ├── frontend-platform.d.ts ├── generic │ ├── CourseAccessErrorPage.jsx │ ├── CourseAccessErrorPage.test.jsx │ ├── PageLoading.jsx │ ├── PageNotFound.jsx │ ├── PageNotFound.test.jsx │ ├── README.md │ ├── assets │ │ ├── openedx_certificate.png │ │ └── openedx_locked_certificate.png │ ├── hooks.js │ ├── hooks.test.jsx │ ├── messages.ts │ ├── model-store │ │ ├── hooks.js │ │ ├── index.js │ │ └── slice.js │ ├── notices │ │ ├── NoticesProvider.jsx │ │ ├── NoticesProvider.test.jsx │ │ ├── api.js │ │ └── index.js │ ├── path-fixes │ │ ├── PathFixesProvider.jsx │ │ ├── PathFixesProvider.test.jsx │ │ └── index.js │ ├── plugin-store │ │ ├── hooks.js │ │ ├── index.js │ │ └── slice.js │ ├── tabs │ │ ├── Tabs.jsx │ │ ├── Tabs.test.jsx │ │ └── useIndexOfLastVisibleChild.js │ ├── upgrade-button │ │ ├── FormattedPricing.jsx │ │ ├── UpgradeButton.jsx │ │ ├── UpgradeNowButton.jsx │ │ ├── index.js │ │ └── messages.ts │ ├── upsell-bullets │ │ ├── UpsellBullets.jsx │ │ ├── UpsellBullets.scss │ │ └── UpsellBullets.test.jsx │ └── user-messages │ │ ├── Alert.jsx │ │ ├── AlertList.jsx │ │ ├── AlertList.test.jsx │ │ ├── UserMessagesContext.js │ │ ├── UserMessagesProvider.jsx │ │ ├── hooks.js │ │ └── index.js ├── i18n │ └── index.js ├── index.jsx ├── index.scss ├── index.test.jsx ├── instructor-toolbar │ ├── InstructorToolbar.jsx │ ├── InstructorToolbar.test.jsx │ ├── index.js │ ├── masquerade-widget │ │ ├── MasqueradeUserNameInput.tsx │ │ ├── MasqueradeWidget.test.tsx │ │ ├── MasqueradeWidget.tsx │ │ ├── MasqueradeWidgetOption.test.tsx │ │ ├── MasqueradeWidgetOption.tsx │ │ ├── data │ │ │ └── api.ts │ │ ├── index.ts │ │ └── messages.ts │ └── messages.ts ├── pacts │ ├── constants.js │ └── frontend-app-learning-lms.json ├── plugin-slots │ ├── ContentIFrameLoaderSlot │ │ ├── README.md │ │ └── index.tsx │ ├── CourseBreadcrumbsSlot │ │ ├── README.md │ │ ├── index.tsx │ │ ├── screenshot_custom.png │ │ └── screenshot_default.png │ ├── CourseExitPluginSlots │ │ ├── CourseExitViewCoursesPluginSlot │ │ │ ├── README.md │ │ │ └── index.tsx │ │ ├── CourseRecommendationsSlot │ │ │ ├── README.md │ │ │ ├── index.tsx │ │ │ └── screenshot_custom.png │ │ ├── DashboardFootnoteLinkPluginSlot │ │ │ ├── README.md │ │ │ └── index.tsx │ │ └── index.jsx │ ├── CourseHomeSectionOutlineSlot │ │ ├── README.md │ │ ├── index.tsx │ │ ├── screenshot_custom.png │ │ └── screenshot_default.png │ ├── CourseOutlineMobileSidebarTriggerSlot │ │ ├── README.md │ │ ├── index.tsx │ │ ├── screenshot_custom.png │ │ └── screenshot_default.png │ ├── CourseOutlineSidebarSlot │ │ ├── README.md │ │ ├── index.tsx │ │ ├── screenshot_custom.png │ │ └── screenshot_default.png │ ├── CourseOutlineSidebarTriggerSlot │ │ ├── README.md │ │ ├── index.tsx │ │ ├── screenshot_custom.png │ │ └── screenshot_default.png │ ├── CourseOutlineTabNotificationsSlot │ │ ├── README.md │ │ └── index.tsx │ ├── FooterSlot │ │ ├── README.md │ │ └── images │ │ │ ├── custom_footer.png │ │ │ └── default_footer.png │ ├── GatedUnitContentMessageSlot │ │ ├── README.md │ │ └── index.tsx │ ├── HeaderSlot │ │ ├── README.md │ │ ├── images │ │ │ └── header_custom_component.png │ │ └── index.jsx │ ├── NextUnitTopNavTriggerSlot │ │ ├── README.md │ │ ├── index.tsx │ │ ├── screenshot_horizontal_nav_custom.png │ │ ├── screenshot_horizontal_nav_default.png │ │ ├── screenshot_unit_at_top_custom.png │ │ └── screenshot_unit_at_top_default.png │ ├── NotificationTraySlot │ │ ├── README.md │ │ └── index.tsx │ ├── NotificationWidgetSlot │ │ ├── README.md │ │ └── index.tsx │ ├── NotificationsDiscussionsSidebarSlot │ │ ├── README.md │ │ ├── index.tsx │ │ ├── screenshot_custom.png │ │ └── screenshot_default.png │ ├── NotificationsDiscussionsSidebarTriggerSlot │ │ ├── README.md │ │ ├── index.tsx │ │ ├── screenshot_custom.png │ │ └── screenshot_default.png │ ├── ProgressCertificateStatusSlot │ │ ├── README.md │ │ ├── index.jsx │ │ ├── screenshot_custom.png │ │ └── screenshot_default.png │ ├── ProgressTabCertificateStatusMainBodySlot │ │ ├── README.md │ │ ├── images │ │ │ └── progress_tab_certificate_status_slot.png │ │ └── index.jsx │ ├── ProgressTabCertificateStatusSidePanelSlot │ │ ├── README.md │ │ ├── images │ │ │ └── progress_tab_certificate_status_slot.png │ │ └── index.jsx │ ├── ProgressTabCourseGradeSlot │ │ ├── README.md │ │ ├── images │ │ │ └── progress_tab_course_grade_slot.png │ │ └── index.jsx │ ├── ProgressTabGradeBreakdownSlot │ │ ├── README.md │ │ ├── images │ │ │ └── progress_tab_grade_breakdown_slot.png │ │ └── index.jsx │ ├── ProgressTabRelatedLinksSlot │ │ ├── README.md │ │ ├── images │ │ │ └── progress_tab_related_links_slot.png │ │ └── index.jsx │ ├── README.md │ ├── SequenceContainerSlot │ │ ├── README.md │ │ ├── images │ │ │ └── post_sequence_container.png │ │ └── index.jsx │ ├── SequenceNavigationSlot │ │ ├── README.md │ │ ├── index.jsx │ │ ├── screenshot_custom.png │ │ └── screenshot_default.png │ └── UnitTitleSlot │ │ ├── README.md │ │ ├── images │ │ └── screenshot_custom.png │ │ └── index.jsx ├── preferences-unsubscribe │ ├── data │ │ └── api.js │ ├── index.jsx │ ├── index.test.jsx │ └── messages.ts ├── product-tours │ ├── AbandonTour.jsx │ ├── CoursewareTour.jsx │ ├── ExistingUserCourseHomeTour.jsx │ ├── GenericTourFormattedMessages.jsx │ ├── ProductTours.jsx │ ├── ProductTours.test.jsx │ ├── data │ │ ├── api.js │ │ ├── index.js │ │ ├── slice.js │ │ └── thunks.js │ ├── messages.ts │ └── newUserCourseHomeTour │ │ ├── LaunchCourseHomeTourButton.jsx │ │ ├── NewUserCourseHomeTour.jsx │ │ ├── NewUserCourseHomeTourModal.jsx │ │ ├── NewUserCourseHomeTourModal.scss │ │ └── course_home_tour_modal_hero.png ├── setupTest.js ├── shared │ ├── README.md │ ├── access.js │ ├── data │ │ └── __factories__ │ │ │ ├── block.factory.js │ │ │ ├── courseBlocks.factory.js │ │ │ ├── courseMetadataBase.factory.js │ │ │ ├── index.js │ │ │ └── tab.factory.js │ ├── effort-estimate │ │ ├── EffortEstimate.jsx │ │ ├── index.js │ │ └── messages.ts │ ├── links.jsx │ └── streak-celebration │ │ ├── StreakCelebrationModal.jsx │ │ ├── StreakCelebrationModal.scss │ │ ├── StreakCelebrationModal.test.jsx │ │ ├── assets │ │ ├── Streak_desktop.png │ │ └── Streak_mobile.png │ │ ├── index.js │ │ ├── messages.ts │ │ └── utils.jsx ├── store.ts ├── tab-page │ ├── LoadedTabPage.jsx │ ├── LoadedTabPage.test.jsx │ ├── TabContainer.jsx │ ├── TabContainer.test.jsx │ ├── TabPage.jsx │ ├── TabPage.test.jsx │ ├── index.js │ └── messages.ts ├── tests │ ├── MockedPluginSlot.jsx │ └── MockedPluginSlot.test.jsx └── utils.ts ├── tsconfig.json ├── webpack.dev.config.js └── webpack.prod.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | README.rst 4 | LICENSE 5 | .babelrc 6 | .eslintignore 7 | .eslintrc.json 8 | .gitignore 9 | .npmignore 10 | commitlint.config.js 11 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # See README.rst for explanations of these. 2 | # If you add a new learning MFE-specific variable, please note it there! 3 | 4 | NODE_ENV='production' 5 | 6 | ACCESS_TOKEN_COOKIE_NAME='' 7 | APP_ID='learning' 8 | BASE_URL='' 9 | CONTACT_URL='' 10 | CREDENTIALS_BASE_URL='' 11 | CREDIT_HELP_LINK_URL='' 12 | CSRF_TOKEN_API_PATH='' 13 | DISCOVERY_API_BASE_URL='' 14 | DISCUSSIONS_MFE_BASE_URL='' 15 | ECOMMERCE_BASE_URL='' 16 | ENABLE_JUMPNAV='true' 17 | ENABLE_NOTICES='' 18 | ENTERPRISE_LEARNER_PORTAL_HOSTNAME='' 19 | ENTERPRISE_LEARNER_PORTAL_URL='' 20 | EXAMS_BASE_URL='' 21 | FAVICON_URL='' 22 | IGNORED_ERROR_REGEX='' 23 | INSIGHTS_BASE_URL='' 24 | LANGUAGE_PREFERENCE_COOKIE_NAME='' 25 | LMS_BASE_URL='' 26 | LOGIN_URL='' 27 | LOGOUT_URL='' 28 | LOGO_URL='' 29 | LOGO_TRADEMARK_URL='' 30 | LOGO_WHITE_URL='' 31 | LEGACY_THEME_NAME='' 32 | MARKETING_SITE_BASE_URL='' 33 | ORDER_HISTORY_URL='' 34 | PROCTORED_EXAM_FAQ_URL='' 35 | PROCTORED_EXAM_RULES_URL='' 36 | REFRESH_ACCESS_TOKEN_ENDPOINT='' 37 | SEARCH_CATALOG_URL='' 38 | SEGMENT_KEY='' 39 | SESSION_COOKIE_DOMAIN='' 40 | SITE_NAME='' 41 | SOCIAL_UTM_MILESTONE_CAMPAIGN='' 42 | STUDIO_BASE_URL='' 43 | SUPPORT_URL='' 44 | SUPPORT_URL_CALCULATOR_MATH='' 45 | SUPPORT_URL_ID_VERIFICATION='' 46 | SUPPORT_URL_VERIFIED_CERTIFICATE='' 47 | TERMS_OF_SERVICE_URL='' 48 | TWITTER_HASHTAG='' 49 | TWITTER_URL='' 50 | USER_INFO_COOKIE_NAME='' 51 | OPTIMIZELY_FULL_STACK_SDK_KEY='' 52 | SHOW_UNGRADED_ASSIGNMENT_PROGRESS='' 53 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/* 2 | dist/ 3 | packages/ 4 | node_modules/ 5 | jest.config.js 6 | env.config.jsx 7 | example.env.config.jsx 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | const { createConfig } = require('@openedx/frontend-build'); 3 | 4 | const config = createConfig('eslint', { 5 | rules: { 6 | // TODO: all these rules should be renabled/addressed. temporarily turned off to unblock a release. 7 | 'react-hooks/rules-of-hooks': 'off', 8 | 'react-hooks/exhaustive-deps': 'off', 9 | 'import/no-extraneous-dependencies': 'off', 10 | 'no-restricted-exports': 'off', 11 | 'react/jsx-no-useless-fragment': 'off', 12 | 'react/no-unknown-property': 'off', 13 | 'func-names': 'off', 14 | }, 15 | settings: { 16 | 'import/resolver': { 17 | webpack: { 18 | config: 'webpack.prod.config.js', 19 | }, 20 | }, 21 | }, 22 | }); 23 | 24 | module.exports = config; 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Adding new check for github-actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.github/workflows/add-depr-ticket-to-depr-board.yml: -------------------------------------------------------------------------------- 1 | # Run the workflow that adds new tickets that are either: 2 | # - labelled "DEPR" 3 | # - title starts with "[DEPR]" 4 | # - body starts with "Proposal Date" (this is the first template field) 5 | # to the org-wide DEPR project board 6 | 7 | name: Add newly created DEPR issues to the DEPR project board 8 | 9 | on: 10 | issues: 11 | types: [opened] 12 | 13 | jobs: 14 | routeissue: 15 | uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master 16 | secrets: 17 | GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} 18 | GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} 19 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/add-issue-to-btr-project.yml: -------------------------------------------------------------------------------- 1 | # Run the workflow that adds new tickets that are labelled "release testing" 2 | # to the org-wide BTR project board 3 | 4 | name: Add release testing issues to the BTR project board 5 | 6 | on: 7 | issues: 8 | types: [labeled] 9 | # This workflow is triggered when an issue is labeled with 'release testing'. 10 | # It adds the issue to the BTR project and applies the 'needs triage' label 11 | # if it doesn't already have it. 12 | 13 | jobs: 14 | handle-release-testing: 15 | uses: openedx/.github/.github/workflows/add-issue-to-btr-project.yml@master 16 | secrets: 17 | GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} 18 | GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} 19 | -------------------------------------------------------------------------------- /.github/workflows/add-remove-label-on-comment.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "label: " it tries to apply 3 | # the label indicated in rest of comment. 4 | # If the comment starts with "remove label: ", it tries 5 | # to remove the indicated label. 6 | # Note: Labels are allowed to have spaces and this script does 7 | # not parse spaces (as often a space is legitimate), so the command 8 | # "label: really long lots of words label" will apply the 9 | # label "really long lots of words label" 10 | 11 | name: Allows for the adding and removing of labels via comment 12 | 13 | on: 14 | issue_comment: 15 | types: [created] 16 | 17 | jobs: 18 | add_remove_labels: 19 | uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | # Run commitlint on the commit messages in a pull request. 2 | 3 | name: Lint Commit Messages 4 | 5 | on: 6 | - pull_request 7 | 8 | jobs: 9 | commitlint: 10 | uses: openedx/.github/.github/workflows/commitlint.yml@master 11 | -------------------------------------------------------------------------------- /.github/workflows/lockfileversion-check.yml: -------------------------------------------------------------------------------- 1 | #check package-lock file version 2 | 3 | name: Lockfile Version check 4 | 5 | on: 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | 11 | jobs: 12 | version-check: 13 | uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master 14 | -------------------------------------------------------------------------------- /.github/workflows/self-assign-issue.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "assign me" it assigns the author to the 3 | # ticket (case insensitive) 4 | 5 | name: Assign comment author to ticket if they say "assign me" 6 | on: 7 | issue_comment: 8 | types: [created] 9 | 10 | jobs: 11 | self_assign_by_comment: 12 | uses: openedx/.github/.github/workflows/self-assign-issue.yml@master 13 | -------------------------------------------------------------------------------- /.github/workflows/update-browserslist-db.yml: -------------------------------------------------------------------------------- 1 | name: Update Browserslist DB 2 | on: 3 | schedule: 4 | - cron: '0 0 * * 1' 5 | workflow_dispatch: 6 | 7 | jobs: 8 | update-browserslist: 9 | uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master 10 | 11 | secrets: 12 | requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }} 13 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: validate 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - '**' 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version-file: '.nvmrc' 17 | - run: make validate.ci 18 | - name: Archive code coverage results 19 | uses: actions/upload-artifact@v4 20 | with: 21 | name: code-coverage-report 22 | path: coverage/*.* 23 | coverage: 24 | runs-on: ubuntu-latest 25 | needs: tests 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Download code coverage results 29 | uses: actions/download-artifact@v4 30 | with: 31 | name: code-coverage-report 32 | - name: Upload coverage 33 | uses: codecov/codecov-action@v5 34 | with: 35 | fail_ci_if_error: true 36 | token: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .eslintcache 3 | .idea 4 | *.swp 5 | *.swo 6 | node_modules 7 | npm-debug.log 8 | coverage 9 | env.config.* 10 | 11 | dist/ 12 | src/i18n/transifex_input.json 13 | temp/babel-plugin-react-intl 14 | logs 15 | 16 | ### pyenv ### 17 | .python-version 18 | 19 | ### Editors ### 20 | *~ 21 | /temp 22 | /.vscode 23 | 24 | # Local package dependencies 25 | module.config.js 26 | 27 | # Local environment overrides 28 | .env.private 29 | 30 | src/i18n/messages/ 31 | 32 | env.config.jsx 33 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .eslintignore 2 | .eslintrc.json 3 | .gitignore 4 | .travis.yml 5 | docker-compose.yml 6 | Dockerfile 7 | Makefile 8 | npm-debug.log 9 | 10 | coverage 11 | node_modules 12 | public 13 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | # This file records information about this repo. Its use is described in OEP-55: 2 | # https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html 3 | 4 | apiVersion: backstage.io/v1alpha1 5 | kind: Component 6 | metadata: 7 | name: 'frontend-app-learning' 8 | description: "This is the Learning MFE, which renders all learner-facing course pages." 9 | links: 10 | - url: "https://github.com/openedx/frontend-app-learning" 11 | title: "Learning MFE" 12 | icon: "Web" 13 | annotations: 14 | openedx.org/arch-interest-groups: "" 15 | openedx.org/release: "master" 16 | spec: 17 | owner: group:committers-frontend-app-learning 18 | type: 'website' 19 | lifecycle: 'production' 20 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 0% 7 | patch: 8 | default: 9 | target: auto 10 | threshold: 0% 11 | -------------------------------------------------------------------------------- /docs/decisions/0001-record-architecture-decisions.rst: -------------------------------------------------------------------------------- 1 | 1. Record Architecture Decisions 2 | -------------------------------- 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | We would like to keep a historical record on the architectural 13 | decisions we make with this app as it evolves over time. 14 | 15 | Decision 16 | -------- 17 | 18 | We will use Architecture Decision Records, as described by 19 | Michael Nygard in `Documenting Architecture Decisions`_ 20 | 21 | .. _Documenting Architecture Decisions: http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions 22 | 23 | Consequences 24 | ------------ 25 | 26 | See Michael Nygard's article, linked above. 27 | 28 | References 29 | ---------- 30 | 31 | * https://resources.sei.cmu.edu/asset_files/Presentation/2017_017_001_497746.pdf 32 | * https://github.com/npryce/adr-tools/tree/master/doc/adr 33 | -------------------------------------------------------------------------------- /docs/decisions/0003-course-home-decisions.md: -------------------------------------------------------------------------------- 1 | # Course Home Decisions 2 | 3 | The course home page is not complete as of this writing. 4 | 5 | It was added to the MFE as a proof of concept for the Engagement theme's Always Available squad, as they were intending to do some work in the legacy course home page in the LMS, and we wanted to understand whether it would be more easily done in this application. 6 | 7 | It uses the same APIs as the courseware page, for the most part. This may not always be the case, but it is for now. Differing API shapes may be faster for both pages. 8 | -------------------------------------------------------------------------------- /docs/decisions/0006-thunk-and-api-naming.md: -------------------------------------------------------------------------------- 1 | # Naming API functions and redux thunks 2 | 3 | Because API functions and redux thunks are two parts of a larger process, we've informally settled on some naming conventions for them to help differentiate the type of code we're looking at. 4 | 5 | ## API Functions 6 | 7 | This micro-frontend follows a pattern of naming API functions with a prefix for their HTTP verb. 8 | 9 | Examples: 10 | 11 | `getCourseBlocks` - The GET request we make to load course blocks data. 12 | `postSequencePosition` - The POST request for saving sequence position. 13 | 14 | ## Redux Thunks 15 | 16 | Meanwhile, we use a different set of verbs for redux thunks to differentiate them from the API functions. For instance, we use the `fetch` prefix for loading data (primarily via GET requests), and `save` for sending data back to the server (primarily via POST or PATCH requests) 17 | 18 | Examples: 19 | 20 | `fetchCourse` - The thunk for getting course data across several APIs. 21 | `fetchSequence` - The thunk for the process of retrieving sequence data. 22 | `saveSequencePosition` - Wraps the POST request for sending sequence position back to the server. 23 | 24 | The verb prefixes for thunks aren't perfect - but they're a little more 'friendly' and semantically meaningful than the HTTP verbs used for APIs. So far we have `fetch`, `save`, `check`, `reset`, etc. 25 | -------------------------------------------------------------------------------- /example.env.config.jsx: -------------------------------------------------------------------------------- 1 | import UnitTranslationPlugin from '@edx/unit-translation-selector-plugin'; 2 | import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework'; 3 | 4 | // Load environment variables from .env file 5 | const config = { 6 | ...process.env, 7 | pluginSlots: { 8 | unit_title_plugin: { 9 | plugins: [ 10 | { 11 | op: PLUGIN_OPERATIONS.Insert, 12 | widget: { 13 | id: 'unit_title_plugin', 14 | type: DIRECT_PLUGIN, 15 | priority: 1, 16 | RenderWidget: UnitTranslationPlugin, 17 | }, 18 | }, 19 | ], 20 | }, 21 | }, 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /global-setup.js: -------------------------------------------------------------------------------- 1 | // Force all tests to run in UTC to prevent tests from being sensitive to host timezone. 2 | module.exports = async () => { 3 | process.env.TZ = 'UTC'; 4 | }; 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { createConfig } = require('@openedx/frontend-build'); 2 | 3 | const config = createConfig('jest', { 4 | setupFilesAfterEnv: [ 5 | '/src/setupTest.js', 6 | ], 7 | coveragePathIgnorePatterns: [ 8 | 'src/setupTest.js', 9 | 'src/i18n', 10 | 'src/.*\\.exp\\..*', 11 | ], 12 | moduleNameMapper: { 13 | // See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown 14 | 'react-markdown': '/node_modules/react-markdown/react-markdown.min.js', 15 | '@src/(.*)': '/src/$1', 16 | // Explicit mapping to ensure Jest resolves the module correctly 17 | '@edx/frontend-lib-special-exams': '/node_modules/@edx/frontend-lib-special-exams', 18 | }, 19 | testTimeout: 30000, 20 | globalSetup: "./global-setup.js", 21 | verbose: true, 22 | testEnvironment: 'jsdom', 23 | }); 24 | 25 | // delete config.testURL; 26 | 27 | config.reporters = [...(config.reporters || []), ["jest-console-group-reporter", { 28 | // change this setting if need to see less details for each test 29 | // reportType: "summary" | "details", 30 | // enable: true | false, 31 | afterEachTest: { 32 | enable: true, 33 | filePaths: false, 34 | reportType: "details", 35 | }, 36 | afterAllTests: { 37 | reportType: "summary", 38 | enable: true, 39 | filePaths: true, 40 | }, 41 | }]]; 42 | 43 | module.exports = config; 44 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Course | <%= process.env.SITE_NAME %> 5 | 6 | 7 | 8 | 9 | <% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %> 10 | 11 | <% } %> 12 | <% if (htmlWebpackPlugin.options.META_TAG_ROBOTS_CONTENT_ATTR) { %> 13 | 14 | <% } %> 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /public/static/LmsHtmlFragment.css: -------------------------------------------------------------------------------- 1 | body a { 2 | color: #00688D; 3 | } 4 | 5 | body.inline-link a { 6 | text-decoration: underline; 7 | } 8 | 9 | body.small { 10 | font-size: 0.875rem; 11 | } 12 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "patch": { 6 | "automerge": true 7 | }, 8 | "rebaseStalePrs": true, 9 | "packageRules": [ 10 | { 11 | "matchPackagePatterns": ["@edx", "@openedx"], 12 | "matchUpdateTypes": ["minor", "patch"], 13 | "automerge": true 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/alerts/access-expiration-alert/index.js: -------------------------------------------------------------------------------- 1 | export { default, useAccessExpirationMasqueradeBanner } from './hooks'; 2 | -------------------------------------------------------------------------------- /src/alerts/access-expiration-alert/messages.ts: -------------------------------------------------------------------------------- 1 | import { defineMessages } from '@edx/frontend-platform/i18n'; 2 | 3 | const messages = defineMessages({ 4 | upgradeNow: { 5 | id: 'learning.accessExpiration.upgradeNow', 6 | defaultMessage: 'Upgrade now', 7 | description: 'The anchor text for the upgrading link', 8 | }, 9 | }); 10 | 11 | export default messages; 12 | -------------------------------------------------------------------------------- /src/alerts/active-enteprise-alert/ActiveEnterpriseAlert.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getConfig } from '@edx/frontend-platform'; 3 | import { 4 | initializeTestStore, render, screen, 5 | } from '../../setupTest'; 6 | import ActiveEnterpriseAlert from './ActiveEnterpriseAlert'; 7 | 8 | describe('ActiveEnterpriseAlert', () => { 9 | const mockData = { 10 | payload: { 11 | text: 'test message', 12 | courseId: 'test-course-id', 13 | }, 14 | }; 15 | beforeAll(async () => { 16 | await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }); 17 | }); 18 | 19 | it('Shows alert message and links', () => { 20 | render(); 21 | expect(screen.getByRole('alert')).toBeInTheDocument(); 22 | expect(screen.getByText('test message', { exact: false })).toBeInTheDocument(); 23 | expect(screen.getByRole('link', { name: 'change enterprise now' })).toHaveAttribute('href', `${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=http%3A%2F%2Flocalhost%2Fcourse%2Ftest-course-id%2Fhome`); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/alerts/active-enteprise-alert/hooks.js: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { ALERT_TYPES, useAlert } from '../../generic/user-messages'; 3 | import { useModel } from '../../generic/model-store'; 4 | 5 | const ActiveEnterpriseAlert = React.lazy(() => import('./ActiveEnterpriseAlert')); 6 | 7 | export default function useActiveEnterpriseAlert(courseId) { 8 | const { courseAccess } = useModel('courseHomeMeta', courseId); 9 | /** 10 | * This alert should render if 11 | * 1. course access code is incorrect_active_enterprise 12 | */ 13 | const isVisible = courseAccess && !courseAccess.hasAccess && courseAccess.errorCode === 'incorrect_active_enterprise'; 14 | 15 | const payload = useMemo(() => ({ 16 | text: courseAccess && courseAccess.userMessage, 17 | courseId, 18 | }), [courseAccess, courseId]); 19 | useAlert(isVisible, { 20 | code: 'clientActiveEnterpriseAlert', 21 | topic: 'outline', 22 | dismissible: false, 23 | type: ALERT_TYPES.ERROR, 24 | payload, 25 | }); 26 | 27 | return { clientActiveEnterpriseAlert: ActiveEnterpriseAlert }; 28 | } 29 | -------------------------------------------------------------------------------- /src/alerts/active-enteprise-alert/index.js: -------------------------------------------------------------------------------- 1 | import useActiveEnterpriseAlert from './hooks'; 2 | 3 | export default useActiveEnterpriseAlert; 4 | -------------------------------------------------------------------------------- /src/alerts/active-enteprise-alert/messages.ts: -------------------------------------------------------------------------------- 1 | import { defineMessages } from '@edx/frontend-platform/i18n'; 2 | 3 | const messages = defineMessages({ 4 | changeActiveEnterpriseLowercase: { 5 | id: 'learning.activeEnterprise.change.alert', 6 | defaultMessage: 'change enterprise now', 7 | description: 'Text in a link, prompting the user to change active enterprise. Used in learning.activeEnterprise.change.alert"', 8 | }, 9 | }); 10 | 11 | export default messages; 12 | -------------------------------------------------------------------------------- /src/alerts/course-start-alert/index.js: -------------------------------------------------------------------------------- 1 | export { default, useCourseStartMasqueradeBanner } from './hooks'; 2 | -------------------------------------------------------------------------------- /src/alerts/enrollment-alert/clickHook.js: -------------------------------------------------------------------------------- 1 | import { useContext, useState, useCallback } from 'react'; 2 | import { sendTrackEvent } from '@edx/frontend-platform/analytics'; 3 | 4 | import { UserMessagesContext, ALERT_TYPES } from '../../generic/user-messages'; 5 | 6 | import { postCourseEnrollment } from './data/api'; 7 | 8 | // Separated into its own file to avoid a circular dependency inside this directory 9 | 10 | function useEnrollClickHandler(courseId, orgId, successText) { 11 | const [loading, setLoading] = useState(false); 12 | const { addFlash } = useContext(UserMessagesContext); 13 | const enrollClickHandler = useCallback(() => { 14 | setLoading(true); 15 | postCourseEnrollment(courseId).then(() => { 16 | addFlash({ 17 | dismissible: true, 18 | flash: true, 19 | text: successText, 20 | type: ALERT_TYPES.SUCCESS, 21 | topic: 'course', 22 | }); 23 | setLoading(false); 24 | sendTrackEvent('edx.bi.user.course-home.enrollment', { 25 | org_key: orgId, 26 | courserun_key: courseId, 27 | }); 28 | global.location.reload(); 29 | }); 30 | }, [addFlash, courseId, orgId, successText]); 31 | 32 | return { enrollClickHandler, loading }; 33 | } 34 | 35 | export default useEnrollClickHandler; 36 | -------------------------------------------------------------------------------- /src/alerts/enrollment-alert/data/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; 3 | import { getConfig } from '@edx/frontend-platform'; 4 | 5 | export async function postCourseEnrollment(courseId) { 6 | const url = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`; 7 | const { data } = await getAuthenticatedHttpClient().post(url, { course_details: { course_id: courseId } }); 8 | return data; 9 | } 10 | -------------------------------------------------------------------------------- /src/alerts/enrollment-alert/index.js: -------------------------------------------------------------------------------- 1 | export { useEnrollmentAlert as default } from './hooks'; 2 | -------------------------------------------------------------------------------- /src/alerts/logistration-alert/hooks.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import React, { useContext } from 'react'; 3 | import { AppContext } from '@edx/frontend-platform/react'; 4 | import { ALERT_TYPES, useAlert } from '../../generic/user-messages'; 5 | import { useModel } from '../../generic/model-store'; 6 | 7 | const LogistrationAlert = React.lazy(() => import('./LogistrationAlert')); 8 | 9 | export function useLogistrationAlert(courseId) { 10 | const { authenticatedUser } = useContext(AppContext); 11 | const outline = useModel('outline', courseId); 12 | const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses; 13 | /** 14 | * This alert should render if 15 | * 1. the user is not authenticated, AND 16 | * 2. the course is private. 17 | */ 18 | const isVisible = authenticatedUser === null && privateOutline; 19 | 20 | useAlert(isVisible, { 21 | code: 'clientLogistrationAlert', 22 | topic: 'outline', 23 | dismissible: false, 24 | type: ALERT_TYPES.ERROR, 25 | }); 26 | 27 | return { clientLogistrationAlert: LogistrationAlert }; 28 | } 29 | -------------------------------------------------------------------------------- /src/alerts/logistration-alert/index.js: -------------------------------------------------------------------------------- 1 | export { useLogistrationAlert as default } from './hooks'; 2 | -------------------------------------------------------------------------------- /src/alerts/logistration-alert/messages.ts: -------------------------------------------------------------------------------- 1 | import { defineMessages } from '@edx/frontend-platform/i18n'; 2 | 3 | const messages = defineMessages({ 4 | accountActivationAlertTitle: { 5 | id: 'account-activation.alert.title', 6 | defaultMessage: 'Activate your account so you can log back in', 7 | description: 'Title for account activation alert which is shown after the registration', 8 | }, 9 | }); 10 | 11 | export default messages; 12 | -------------------------------------------------------------------------------- /src/alerts/sequence-alerts/messages.ts: -------------------------------------------------------------------------------- 1 | import { defineMessages } from '@edx/frontend-platform/i18n'; 2 | 3 | const messages = defineMessages({ 4 | entranceExamTextNotPassing: { 5 | id: 'learn.sequence.entranceExamTextNotPassing', 6 | defaultMessage: 'To access course materials, you must score {entranceExamMinimumScorePct}% or higher on this exam. Your current score is {entranceExamCurrentScore}%.', 7 | }, 8 | entranceExamTextPassed: { 9 | id: 'learn.sequence.entranceExamTextPassed', 10 | defaultMessage: 'Your score is {entranceExamCurrentScore}%. You have passed the entrance exam.', 11 | }, 12 | }); 13 | 14 | export default messages; 15 | -------------------------------------------------------------------------------- /src/course-home/courseware-search/CoursewareSearchEmpty.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useIntl } from '@edx/frontend-platform/i18n'; 3 | import messages from './messages'; 4 | 5 | const CoursewareSearchEmpty = () => { 6 | const intl = useIntl(); 7 | return ( 8 |
9 |

{intl.formatMessage(messages.searchResultsNone)}

10 |
11 | ); 12 | }; 13 | 14 | export default CoursewareSearchEmpty; 15 | -------------------------------------------------------------------------------- /src/course-home/courseware-search/CoursewareSearchEmpty.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | initializeMockApp, 4 | render, 5 | screen, 6 | } from '../../setupTest'; 7 | import CoursewareSearchEmpty from './CoursewareSearchEmpty'; 8 | 9 | function renderComponent() { 10 | const { container } = render(); 11 | return container; 12 | } 13 | 14 | describe('CoursewareSearchEmpty', () => { 15 | beforeAll(async () => { 16 | initializeMockApp(); 17 | }); 18 | 19 | it('should match the snapshot', () => { 20 | renderComponent(); 21 | 22 | expect(screen.getByTestId('no-results')).toMatchSnapshot(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/course-home/courseware-search/CoursewareSearchResults.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | initializeMockApp, 4 | render, 5 | screen, 6 | } from '../../setupTest'; 7 | import CoursewareSearchResults from './CoursewareSearchResults'; 8 | import messages from './messages'; 9 | import searchResultsFactory from './test-data/search-results-factory'; 10 | 11 | jest.mock('react-redux'); 12 | 13 | function renderComponent({ results }) { 14 | const { container } = render(); 15 | return container; 16 | } 17 | 18 | describe('CoursewareSearchResults', () => { 19 | beforeAll(async () => { 20 | initializeMockApp(); 21 | }); 22 | 23 | describe('when an empty array is provided', () => { 24 | beforeEach(() => { renderComponent({ results: [] }); }); 25 | 26 | it('should render a "no results found" message.', () => { 27 | expect(screen.getByTestId('no-results').textContent).toBe(messages.searchResultsNone.defaultMessage); 28 | }); 29 | }); 30 | 31 | describe('when list of results is provided', () => { 32 | beforeEach(() => { 33 | const { results } = searchResultsFactory('course'); 34 | renderComponent({ results }); 35 | }); 36 | 37 | it('should match the snapshot', () => { 38 | expect(screen.getByTestId('search-results')).toMatchSnapshot(); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/course-home/courseware-search/__snapshots__/CoursewareSearchEmpty.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CoursewareSearchEmpty should match the snapshot 1`] = ` 4 |

8 | No results found. 9 |

10 | `; 11 | -------------------------------------------------------------------------------- /src/course-home/courseware-search/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | export { default as CoursewareSearchToggle } from './CoursewareSearchToggle'; 3 | export { default as CoursewareSearch } from './CoursewareSearch'; 4 | -------------------------------------------------------------------------------- /src/course-home/courseware-search/test-data/search-results-factory.js: -------------------------------------------------------------------------------- 1 | import { camelCaseObject } from '@edx/frontend-platform'; 2 | import mockedData from './mocked-response.json'; 3 | import mapSearchResponse from '../map-search-response'; 4 | 5 | function searchResultsFactory(searchKeywords = '', moreInfo = {}) { 6 | const data = camelCaseObject(mockedData); 7 | const info = mapSearchResponse(data, searchKeywords); 8 | 9 | const result = { 10 | ...info, 11 | ...moreInfo, 12 | }; 13 | 14 | return result; 15 | } 16 | 17 | export default searchResultsFactory; 18 | -------------------------------------------------------------------------------- /src/course-home/data/__factories__/index.js: -------------------------------------------------------------------------------- 1 | import './courseHomeMetadata.factory'; 2 | import './datesTabData.factory'; 3 | import './outlineTabData.factory'; 4 | import './progressTabData.factory'; 5 | import './upgradeNotificationData.factory'; 6 | -------------------------------------------------------------------------------- /src/course-home/data/__factories__/upgradeNotificationData.factory.js: -------------------------------------------------------------------------------- 1 | import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies 2 | 3 | Factory.define('upgradeNotificationData') 4 | .option('host', 'http://localhost:18000') 5 | .option('dateBlocks', []) 6 | .option('offer', null) 7 | .option('userTimezone', null) 8 | .option('contentTypeGatingEnabled', false) 9 | .attr('courseId', 'course-v1:edX+DemoX+Demo_Course') 10 | .attr('upsellPageName', 'test') 11 | .attr('verifiedMode', ['host'], (host) => ({ 12 | access_expiration_date: '2050-01-01T12:00:00', 13 | currency: 'USD', 14 | currencySymbol: '$', 15 | price: 149, 16 | sku: 'ABCD1234', 17 | upgradeUrl: `${host}/dashboard`, 18 | })) 19 | .attr('org', 'edX') 20 | .attrs({ 21 | accessExpiration: { 22 | expiration_date: '1950-07-13T02:04:49.040006Z', 23 | }, 24 | }) 25 | .attr('timeOffsetMillis', 0); 26 | -------------------------------------------------------------------------------- /src/course-home/data/api.test.js: -------------------------------------------------------------------------------- 1 | import { getTimeOffsetMillis } from './api'; 2 | 3 | describe('Calculate the time offset properly', () => { 4 | it('Should return 0 if the headerDate is not set', async () => { 5 | const offset = getTimeOffsetMillis(undefined, undefined, undefined); 6 | expect(offset).toBe(0); 7 | }); 8 | 9 | it('Should return the offset', async () => { 10 | const headerDate = '2021-04-13T11:01:58.135Z'; 11 | const requestTime = new Date('2021-04-12T11:01:57.135Z'); 12 | const responseTime = new Date('2021-04-12T11:01:58.635Z'); 13 | const offset = getTimeOffsetMillis(headerDate, requestTime, responseTime); 14 | expect(offset).toBe(86398750); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/course-home/data/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | fetchDatesTab, 3 | fetchOutlineTab, 4 | fetchProgressTab, 5 | resetDeadlines, 6 | deprecatedSaveCourseGoal, 7 | saveWeeklyLearningGoal, 8 | } from './thunks'; 9 | 10 | export { reducer } from './slice'; 11 | -------------------------------------------------------------------------------- /src/course-home/dates-tab/index.jsx: -------------------------------------------------------------------------------- 1 | import DatesTab from './DatesTab'; 2 | 3 | export default DatesTab; 4 | -------------------------------------------------------------------------------- /src/course-home/dates-tab/timeline/Day.scss: -------------------------------------------------------------------------------- 1 | $dot-radius: 0.3rem; 2 | $dot-size: $dot-radius * 2; 3 | $offset: $dot-radius * 1.5; 4 | 5 | .dates-day { 6 | position: relative; 7 | } 8 | 9 | .dates-line-top { 10 | display: inline-block; 11 | position: absolute; 12 | left: $offset; 13 | top: 0; 14 | height: $offset; 15 | z-index: 0; 16 | } 17 | 18 | .dates-dot { 19 | display: inline-block; 20 | position: absolute; 21 | border-radius: 50%; 22 | left: $dot-radius * 0.5; // save room for today's larger size 23 | top: $offset; 24 | height: $dot-size; 25 | width: $dot-size; 26 | z-index: 1; 27 | 28 | &.dates-bg-today { 29 | left: 0; 30 | top: $offset - $dot-radius; 31 | height: $dot-size * 1.5; 32 | width: $dot-size * 1.5; 33 | } 34 | } 35 | 36 | .dates-line-bottom { 37 | display: inline-block; 38 | position: absolute; 39 | top: $offset + $dot-size; 40 | bottom: 0; 41 | left: $offset; 42 | z-index: 0; 43 | } 44 | 45 | .dates-bg-today { 46 | background: #ffdb87; 47 | } 48 | -------------------------------------------------------------------------------- /src/course-home/dates-tab/utils.jsx: -------------------------------------------------------------------------------- 1 | function daycmp(a, b) { 2 | if (a.getFullYear() < b.getFullYear()) { return -1; } 3 | if (a.getFullYear() > b.getFullYear()) { return 1; } 4 | if (a.getMonth() < b.getMonth()) { return -1; } 5 | if (a.getMonth() > b.getMonth()) { return 1; } 6 | if (a.getDate() < b.getDate()) { return -1; } 7 | if (a.getDate() > b.getDate()) { return 1; } 8 | return 0; 9 | } 10 | 11 | // item is a date block returned from the API 12 | function isLearnerAssignment(item) { 13 | return item.learnerHasAccess && item.dateType === 'assignment-due-date'; 14 | } 15 | 16 | export { daycmp, isLearnerAssignment }; 17 | -------------------------------------------------------------------------------- /src/course-home/discussion-tab/DiscussionTab.jsx: -------------------------------------------------------------------------------- 1 | import { getConfig } from '@edx/frontend-platform'; 2 | import React, { useState } from 'react'; 3 | import { useSelector } from 'react-redux'; 4 | import { useParams, generatePath, useNavigate } from 'react-router-dom'; 5 | import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks'; 6 | 7 | const DiscussionTab = () => { 8 | const { courseId } = useSelector(state => state.courseHome); 9 | const { path } = useParams(); 10 | const [originalPath] = useState(path); 11 | const navigate = useNavigate(); 12 | 13 | const [, iFrameHeight] = useIFrameHeight(); 14 | useIFramePluginEvents({ 15 | 'discussions.navigate': (payload) => { 16 | const basePath = generatePath('/course/:courseId/discussion', { courseId }); 17 | navigate(`${basePath}/${payload.path}`); 18 | }, 19 | }); 20 | const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`; 21 | return ( 22 |