Compare commits
7 Commits
release/ul
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3961455e3d | ||
|
|
b282bc05df | ||
|
|
d987aed861 | ||
|
|
9ece337504 | ||
|
|
d3235af879 | ||
|
|
d0a8778015 | ||
|
|
f8381e7900 |
1
.env
1
.env
@@ -53,4 +53,3 @@ OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT=''
|
||||
|
||||
@@ -55,4 +55,3 @@ OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'
|
||||
|
||||
4
.github/workflows/validate.yml
vendored
4
.github/workflows/validate.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -13,7 +13,7 @@
|
||||
"@edx/browserslist-config": "1.5.0",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^8.0.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.23.1",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.24.0",
|
||||
"@edx/frontend-lib-special-exams": "^4.0.0",
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
@@ -2379,9 +2379,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-lib-learning-assistant": {
|
||||
"version": "2.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-2.23.1.tgz",
|
||||
"integrity": "sha512-0rDHlE3tlADWOcqKaVIKkMK2YGonbRaYJfmBSgH+Sn6+BFg2e541fn7NC9e5rIaiV1BnMREF7dxyRa/IEYLZLA==",
|
||||
"version": "2.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-2.24.0.tgz",
|
||||
"integrity": "sha512-+RwmKbYxsJ6Ct9scBX3jnxSUuoiW5ed1vbCz9PQiQ8fobuiMM3fokLynIreB5ZVYWvrjSa5OaMwBq1bUXsprZw==",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
||||
@@ -8615,9 +8615,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||
"version": "1.0.30001755",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz",
|
||||
"integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@edx/browserslist-config": "1.5.0",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^8.0.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.23.1",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.24.0",
|
||||
"@edx/frontend-lib-special-exams": "^4.0.0",
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
|
||||
@@ -204,122 +204,122 @@ const messages = defineMessages({
|
||||
notStartedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.notStarted',
|
||||
defaultMessage: 'Not Started',
|
||||
description: 'It indcate that proctortrack onboarding exam hasn’t started yet',
|
||||
description: 'It indcate that proctored onboarding exam hasn’t started yet',
|
||||
},
|
||||
startedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.started',
|
||||
defaultMessage: 'Started',
|
||||
description: 'Label to indicate the starting status of the proctortrack onboarding exam',
|
||||
description: 'Label to indicate the starting status of the proctored onboarding exam',
|
||||
},
|
||||
submittedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.submitted',
|
||||
defaultMessage: 'Submitted',
|
||||
description: 'Label to indicate the submitted status of proctortrack onboarding exam',
|
||||
description: 'Label to indicate the submitted status of proctored onboarding exam',
|
||||
},
|
||||
verifiedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.verified',
|
||||
defaultMessage: 'Verified',
|
||||
description: 'Label to indicate the verified status of the proctortrack onboarding exam',
|
||||
description: 'Label to indicate the verified status of the proctored onboarding exam',
|
||||
},
|
||||
rejectedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
description: 'Label to indicate the rejection status of the proctortrack onboarding exam',
|
||||
description: 'Label to indicate the rejection status of the proctored onboarding exam',
|
||||
},
|
||||
errorProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.error',
|
||||
defaultMessage: 'Error',
|
||||
description: 'Label to indicate that there is error in proctortrack onboarding exam',
|
||||
description: 'Label to indicate that there is error in proctored onboarding exam',
|
||||
},
|
||||
otherCourseApprovedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.otherCourseApproved',
|
||||
defaultMessage: 'Approved in Another Course',
|
||||
description: 'Label to indicate that the proctortrack onboarding exam is verified based on taking onboarding exam on another course',
|
||||
description: 'Label to indicate that the proctored onboarding exam is verified based on taking onboarding exam on another course',
|
||||
},
|
||||
expiringSoonProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.expiringSoon',
|
||||
defaultMessage: 'Expiring Soon',
|
||||
description: 'A label to indicate that proctortrack onboarding exam will expire soon',
|
||||
description: 'A label to indicate that proctored onboarding exam will expire soon',
|
||||
},
|
||||
expiredProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.expired',
|
||||
defaultMessage: 'Expired',
|
||||
description: 'A label to indicate that proctortrack onboarding exam has expired',
|
||||
description: 'A label to indicate that proctored onboarding exam has expired',
|
||||
},
|
||||
proctoringCurrentStatus: {
|
||||
id: 'learning.proctoringPanel.status',
|
||||
defaultMessage: 'Current Onboarding Status:',
|
||||
description: 'The text that precede the status label of proctortrack onboarding exam',
|
||||
description: 'The text that precede the status label of proctored onboarding exam',
|
||||
},
|
||||
notStartedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.notStarted',
|
||||
defaultMessage: 'You have not started your onboarding exam.',
|
||||
description: 'The text that explain the meaning of (not started) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (not started) label of the proctored onboarding exam',
|
||||
},
|
||||
startedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.started',
|
||||
defaultMessage: 'You have started your onboarding exam.',
|
||||
description: 'The text that explain the meaning of (started) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (started) label of the proctored onboarding exam',
|
||||
},
|
||||
submittedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.submitted',
|
||||
defaultMessage: 'You have submitted your onboarding exam.',
|
||||
description: 'The text that explain the meaning of (submitted) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (submitted) label of the proctored onboarding exam',
|
||||
},
|
||||
verifiedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.verified',
|
||||
defaultMessage: 'Your onboarding exam has been approved in this course.',
|
||||
description: 'The text that explain the meaning of (verified) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (verified) label of the proctored onboarding exam',
|
||||
},
|
||||
rejectedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.rejected',
|
||||
defaultMessage: 'Your onboarding exam has been rejected. Please retry onboarding.',
|
||||
description: 'The text that explain the meaning of (rejected) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (rejected) label of the proctored onboarding exam',
|
||||
},
|
||||
errorProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.error',
|
||||
defaultMessage: 'An error has occurred during your onboarding exam. Please retry onboarding.',
|
||||
description: 'The text that explain the meaning of (error) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (error) label of the proctored onboarding exam',
|
||||
},
|
||||
otherCourseApprovedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.otherCourseApproved',
|
||||
defaultMessage: 'Your onboarding exam has been approved in another course.',
|
||||
description: 'The text that explain the meaning of (approved in another course) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (approved in another course) label of the proctored onboarding exam',
|
||||
},
|
||||
otherCourseApprovedProctoringDetail: {
|
||||
id: 'learning.proctoringPanel.detail.otherCourseApproved',
|
||||
defaultMessage: 'If your device has changed, we recommend that you complete this course\'s onboarding exam in order to ensure that your setup still meets the requirements for proctoring.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (approved in another course)',
|
||||
description: 'The text that recommend an action when the status of the proctored onboarding exam is (approved in another course)',
|
||||
},
|
||||
expiringSoonProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.expiringSoon',
|
||||
defaultMessage: 'Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
|
||||
description: 'The text that recommend an action when the status of the proctored onboarding exam is (expiring soon)',
|
||||
},
|
||||
expiredProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.expired',
|
||||
defaultMessage: 'Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expired)',
|
||||
description: 'The text that recommend an action when the status of the proctored onboarding exam is (expired)',
|
||||
},
|
||||
proctoringPanelGeneralInfo: {
|
||||
id: 'learning.proctoringPanel.generalInfo',
|
||||
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
|
||||
description: 'It indicate key and important fact to learner about the importance of taking proctortrack onboarding exam',
|
||||
description: 'It indicate key and important fact to learner about the importance of taking proctored onboarding exam',
|
||||
},
|
||||
proctoringPanelGeneralInfoSubmitted: {
|
||||
id: 'learning.proctoringPanel.generalInfoSubmitted',
|
||||
defaultMessage: 'Your submitted profile is in review.',
|
||||
description: 'The text that explain the meaning of (in review) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (in review) label of the proctored onboarding exam',
|
||||
},
|
||||
proctoringPanelGeneralTime: {
|
||||
id: 'learning.proctoringPanel.generalTime',
|
||||
defaultMessage: 'Onboarding profile review can take 2+ business days.',
|
||||
description: 'This text explain for how long the (in review) status of the proctortrack onboarding exam might remain',
|
||||
description: 'This text explain for how long the (in review) status of the proctored onboarding exam might remain',
|
||||
},
|
||||
proctoringOnboardingButton: {
|
||||
id: 'learning.proctoringPanel.onboardingButton',
|
||||
defaultMessage: 'Complete Onboarding',
|
||||
description: 'Text shown on the button that starts the actual proctortrack onboarding exam when it is released',
|
||||
description: 'Text shown on the button that starts the actual proctored onboarding exam when it is released',
|
||||
},
|
||||
proctoringOnboardingPracticeButton: {
|
||||
id: 'learning.proctoringPanel.onboardingPracticeButton',
|
||||
@@ -329,17 +329,17 @@ const messages = defineMessages({
|
||||
proctoringOnboardingButtonNotOpen: {
|
||||
id: 'learning.proctoringPanel.onboardingButtonNotOpen',
|
||||
defaultMessage: 'Onboarding Opens: {releaseDate}',
|
||||
description: 'It indicate when or from when the learner can take the proctortrack onboarding exam',
|
||||
description: 'It indicate when or from when the learner can take the proctored onboarding exam',
|
||||
},
|
||||
proctoringReviewRequirementsButton: {
|
||||
id: 'learning.proctoringPanel.reviewRequirementsButton',
|
||||
defaultMessage: 'Review instructions and system requirements',
|
||||
description: 'Anchor text for button which redirect leaner to doc or a detailed page about proctortrack onboarding exam',
|
||||
description: 'Anchor text for button which redirect leaner to doc or a detailed page about proctored onboarding exam',
|
||||
},
|
||||
proctoringOnboardingButtonPastDue: {
|
||||
id: 'learning.proctoringPanel.onboardingButtonPastDue',
|
||||
defaultMessage: 'Onboarding Past Due',
|
||||
description: 'Text that show when the deadline of proctortrack onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
|
||||
description: 'Text that show when the deadline of proctored onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
|
||||
},
|
||||
sequenceDueDate: {
|
||||
id: 'learning.outline.sequence-due-date-set',
|
||||
|
||||
20
src/course-tabs/CourseTabLink.tsx
Normal file
20
src/course-tabs/CourseTabLink.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
interface CourseTabLinkProps {
|
||||
slug: string;
|
||||
activeTabSlug?: string;
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const CourseTabLink = ({
|
||||
slug, activeTabSlug, url, title,
|
||||
}: CourseTabLinkProps) => (
|
||||
<a
|
||||
href={url}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
);
|
||||
25
src/course-tabs/CourseTabLinksList.tsx
Normal file
25
src/course-tabs/CourseTabLinksList.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { CourseTabLink } from '@src/course-tabs/CourseTabLink';
|
||||
import React from 'react';
|
||||
|
||||
interface CourseTabLinkListProps {
|
||||
tabs: Array<{
|
||||
title: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
}>,
|
||||
activeTabSlug?: string;
|
||||
}
|
||||
|
||||
export const CourseTabLinksList = ({ tabs, activeTabSlug }: CourseTabLinkListProps) => (
|
||||
<>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<CourseTabLink
|
||||
key={slug}
|
||||
url={url}
|
||||
slug={slug}
|
||||
title={title}
|
||||
activeTabSlug={activeTabSlug}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -1,16 +1,28 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
import Tabs from '../generic/tabs/Tabs';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { CourseTabLinksSlot } from '../plugin-slots/CourseTabLinksSlot';
|
||||
import { CoursewareSearch, CoursewareSearchToggle } from '../course-home/courseware-search';
|
||||
import { useCoursewareSearchState } from '../course-home/courseware-search/hooks';
|
||||
|
||||
import Tabs from '../generic/tabs/Tabs';
|
||||
import messages from './messages';
|
||||
|
||||
interface CourseTabsNavigationProps {
|
||||
activeTabSlug?: string;
|
||||
className?: string | null;
|
||||
tabs: Array<{
|
||||
title: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const CourseTabsNavigation = ({
|
||||
activeTabSlug, className, tabs,
|
||||
}) => {
|
||||
activeTabSlug = undefined,
|
||||
className = null,
|
||||
tabs,
|
||||
}:CourseTabsNavigationProps) => {
|
||||
const intl = useIntl();
|
||||
const { show } = useCoursewareSearchState();
|
||||
|
||||
@@ -23,15 +35,7 @@ const CourseTabsNavigation = ({
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={url}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
<CourseTabLinksSlot tabs={tabs} activeTabSlug={activeTabSlug} />
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="search-toggle">
|
||||
@@ -44,19 +48,4 @@ const CourseTabsNavigation = ({
|
||||
);
|
||||
};
|
||||
|
||||
CourseTabsNavigation.propTypes = {
|
||||
activeTabSlug: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
tabs: PropTypes.arrayOf(PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
slug: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
})).isRequired,
|
||||
};
|
||||
|
||||
CourseTabsNavigation.defaultProps = {
|
||||
activeTabSlug: undefined,
|
||||
className: null,
|
||||
};
|
||||
|
||||
export default CourseTabsNavigation;
|
||||
@@ -8,7 +8,7 @@ import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
|
||||
import { AlertList } from '@src/generic/user-messages';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import Chat from './chat/Chat';
|
||||
import { LearnerToolsSlot } from '../../plugin-slots/LearnerToolsSlot';
|
||||
import SidebarProvider from './sidebar/SidebarContextProvider';
|
||||
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
|
||||
import { NotificationsDiscussionsSidebarTriggerSlot } from '../../plugin-slots/NotificationsDiscussionsSidebarTriggerSlot';
|
||||
@@ -59,7 +59,7 @@ const Course = ({
|
||||
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
|
||||
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
|
||||
);
|
||||
const shouldDisplayChat = windowWidth >= breakpoints.medium.minWidth;
|
||||
const shouldDisplayLearnerTools = windowWidth >= breakpoints.medium.minWidth;
|
||||
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,17 +88,13 @@ const Course = ({
|
||||
isStaff={isStaff}
|
||||
unitId={unitId}
|
||||
/>
|
||||
{shouldDisplayChat && (
|
||||
<>
|
||||
<Chat
|
||||
enabled={course.learningAssistantEnabled}
|
||||
enrollmentMode={course.enrollmentMode}
|
||||
isStaff={isStaff}
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={course.showCalculator || course.notes.enabled}
|
||||
unitId={unitId}
|
||||
/>
|
||||
</>
|
||||
{shouldDisplayLearnerTools && (
|
||||
<LearnerToolsSlot
|
||||
enrollmentMode={course.enrollmentMode}
|
||||
isStaff={isStaff}
|
||||
courseId={courseId}
|
||||
unitId={unitId}
|
||||
/>
|
||||
)}
|
||||
<div className="w-100 d-flex align-items-center">
|
||||
<CourseOutlineMobileSidebarTriggerSlot />
|
||||
|
||||
@@ -13,17 +13,25 @@ import Course from './Course';
|
||||
import setupDiscussionSidebar from './test-utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
|
||||
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
|
||||
checkExamEntry: () => jest.fn(),
|
||||
}));
|
||||
const mockChatTestId = 'fake-chat';
|
||||
jest.mock('@edx/frontend-lib-special-exams', () => {
|
||||
const actual = jest.requireActual('@edx/frontend-lib-special-exams');
|
||||
return {
|
||||
...actual,
|
||||
__esModule: true,
|
||||
// Mock the default export (SequenceExamWrapper) to just render children
|
||||
// eslint-disable-next-line react/prop-types
|
||||
default: ({ children }) => <div data-testid="sequence-exam-wrapper">{children}</div>,
|
||||
};
|
||||
});
|
||||
const mockLearnerToolsTestId = 'fake-learner-tools';
|
||||
jest.mock(
|
||||
'./chat/Chat',
|
||||
// eslint-disable-next-line react/prop-types
|
||||
() => function ({ courseId }) {
|
||||
return <div className="fake-chat" data-testid={mockChatTestId}>Chat contents {courseId} </div>;
|
||||
},
|
||||
'../../plugin-slots/LearnerToolsSlot',
|
||||
() => ({
|
||||
// eslint-disable-next-line react/prop-types
|
||||
LearnerToolsSlot({ courseId }) {
|
||||
return <div className="fake-learner-tools" data-testid={mockLearnerToolsTestId}>LearnerTools contents {courseId} </div>;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const recordFirstSectionCelebration = jest.fn();
|
||||
@@ -360,28 +368,27 @@ describe('Course', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('displays chat when screen is wide enough (browser)', async () => {
|
||||
it('displays learner tools when screen is wide enough (browser)', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
learning_assistant_enabled: true,
|
||||
enrollment: { mode: 'verified' },
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
const { courseware } = testStore.getState();
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
const chat = screen.queryByTestId(mockChatTestId);
|
||||
waitFor(() => expect(chat).toBeInTheDocument());
|
||||
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
|
||||
await waitFor(() => expect(learnerTools).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('does not display chat when screen is too narrow (mobile)', async () => {
|
||||
it('does not display learner tools when screen is too narrow (mobile)', async () => {
|
||||
global.innerWidth = breakpoints.extraSmall.minWidth;
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
learning_assistant_enabled: true,
|
||||
enrollment: { mode: 'verified' },
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
@@ -393,7 +400,7 @@ describe('Course', () => {
|
||||
sequenceId,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
const chat = screen.queryByTestId(mockChatTestId);
|
||||
await expect(chat).not.toBeInTheDocument();
|
||||
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
|
||||
await expect(learnerTools).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Xpert } from '@edx/frontend-lib-learning-assistant';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { ALLOW_UPSELL_MODES, VERIFIED_MODES } from '@src/constants';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
const Chat = ({
|
||||
enabled,
|
||||
enrollmentMode,
|
||||
isStaff,
|
||||
courseId,
|
||||
contentToolsEnabled,
|
||||
unitId,
|
||||
}) => {
|
||||
const {
|
||||
activeAttempt, exam,
|
||||
} = useSelector(state => state.specialExams);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
|
||||
// If is disabled or taking an exam, we don't show the chat.
|
||||
if (!enabled || activeAttempt?.attempt_id || exam?.id) { return null; }
|
||||
|
||||
// If is not staff and doesn't have an enrollment, we don't show the chat.
|
||||
if (!isStaff && !enrollmentMode) { return null; }
|
||||
|
||||
const verifiedMode = VERIFIED_MODES.includes(enrollmentMode); // Enrollment verified
|
||||
const auditMode = (
|
||||
!isStaff
|
||||
&& !verifiedMode
|
||||
&& ALLOW_UPSELL_MODES.includes(enrollmentMode) // Can upgrade course
|
||||
&& getConfig().ENABLE_XPERT_AUDIT
|
||||
);
|
||||
// If user has no access, we don't show the chat.
|
||||
if (!isStaff && !(verifiedMode || auditMode)) { return null; }
|
||||
|
||||
// Date validation
|
||||
const {
|
||||
accessExpiration,
|
||||
start,
|
||||
end,
|
||||
} = course;
|
||||
|
||||
const utcDate = (new Date()).toISOString();
|
||||
const expiration = accessExpiration?.expirationDate || utcDate;
|
||||
const validDate = (
|
||||
(start ? start <= utcDate : true)
|
||||
&& (end ? end >= utcDate : true)
|
||||
&& (auditMode ? expiration >= utcDate : true)
|
||||
);
|
||||
// If date is invalid, we don't show the chat.
|
||||
if (!validDate) { return null; }
|
||||
|
||||
// Use a portal to ensure that component overlay does not compete with learning MFE styles.
|
||||
return createPortal(
|
||||
<Xpert
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={contentToolsEnabled}
|
||||
unitId={unitId}
|
||||
isUpgradeEligible={auditMode}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
Chat.propTypes = {
|
||||
isStaff: PropTypes.bool.isRequired,
|
||||
enabled: PropTypes.bool.isRequired,
|
||||
enrollmentMode: PropTypes.string,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
contentToolsEnabled: PropTypes.bool.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
Chat.defaultProps = {
|
||||
enrollmentMode: null,
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
@@ -1,286 +0,0 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import {
|
||||
initializeMockApp,
|
||||
initializeTestStore,
|
||||
render,
|
||||
screen,
|
||||
} from '../../../setupTest';
|
||||
|
||||
import Chat from './Chat';
|
||||
|
||||
// We do a partial mock to avoid mocking out other exported values (e.g. the reducer).
|
||||
// We mock out the Xpert component, because the Xpert component has its own rules for whether it renders
|
||||
// or not, and this includes the results of API calls it makes. We don't want to test those rules here, just
|
||||
// whether the Xpert is rendered by the Chat component in certain conditions. Instead of actually rendering
|
||||
// Xpert, we render and assert on a mocked component.
|
||||
const mockXpertTestId = 'xpert';
|
||||
|
||||
jest.mock('@edx/frontend-lib-learning-assistant', () => {
|
||||
const originalModule = jest.requireActual('@edx/frontend-lib-learning-assistant');
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
Xpert: () => (<div data-testid={mockXpertTestId}>mocked Xpert</div>),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: jest.fn().mockReturnValue({ ENABLE_XPERT_AUDIT: false }),
|
||||
}));
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
let testCases = [];
|
||||
let enabledTestCases = [];
|
||||
let disabledTestCases = [];
|
||||
const enabledModes = [
|
||||
'professional', 'verified', 'no-id-professional', 'credit', 'masters', 'executive-education',
|
||||
'paid-executive-education', 'paid-bootcamp',
|
||||
];
|
||||
const disabledModes = [null, undefined, 'xyz', 'audit', 'honor', 'unpaid-executive-education', 'unpaid-bootcamp'];
|
||||
|
||||
describe('Chat', () => {
|
||||
let store;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = await initializeTestStore({
|
||||
specialExams: {
|
||||
activeAttempt: {
|
||||
attempt_id: null,
|
||||
},
|
||||
exam: {
|
||||
id: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Generate test cases.
|
||||
enabledTestCases = enabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: true }));
|
||||
disabledTestCases = disabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: false }));
|
||||
testCases = enabledTestCases.concat(disabledTestCases);
|
||||
|
||||
testCases.forEach(test => {
|
||||
it(
|
||||
`visibility determined by ${test.enrollmentMode} enrollment mode when enabled and not isStaff`,
|
||||
async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode={test.enrollmentMode}
|
||||
isStaff={false}
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
if (test.isVisible) {
|
||||
expect(chat).toBeInTheDocument();
|
||||
} else {
|
||||
expect(chat).not.toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Generate test cases.
|
||||
testCases = enabledModes.concat(disabledModes).map((mode) => ({ enrollmentMode: mode, isVisible: true }));
|
||||
testCases.forEach(test => {
|
||||
it('visibility determined by isStaff when enabled and any enrollment mode', async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode={test.enrollmentMode}
|
||||
isStaff
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
if (test.isVisible) {
|
||||
expect(chat).toBeInTheDocument();
|
||||
} else {
|
||||
expect(chat).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Generate the map function used for generating test cases by currying the map function.
|
||||
// In this test suite, visibility depends on whether the enrollment mode is a valid or invalid
|
||||
// enrollment mode for enabling the Chat when the user is not a staff member and the Chat is enabled. Instead of
|
||||
// defining two separate map functions that differ in only one case, curry the function.
|
||||
const generateMapFunction = (areEnabledModes) => (
|
||||
(mode) => (
|
||||
[
|
||||
{
|
||||
enrollmentMode: mode, isStaff: true, enabled: true, isVisible: true,
|
||||
},
|
||||
{
|
||||
enrollmentMode: mode, isStaff: true, enabled: false, isVisible: false,
|
||||
},
|
||||
{
|
||||
enrollmentMode: mode, isStaff: false, enabled: true, isVisible: areEnabledModes,
|
||||
},
|
||||
{
|
||||
enrollmentMode: mode, isStaff: false, enabled: false, isVisible: false,
|
||||
},
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
// Generate test cases.
|
||||
enabledTestCases = enabledModes.map(generateMapFunction(true));
|
||||
disabledTestCases = disabledModes.map(generateMapFunction(false));
|
||||
testCases = enabledTestCases.concat(disabledTestCases);
|
||||
testCases = testCases.flat();
|
||||
testCases.forEach(test => {
|
||||
it(
|
||||
`visibility determined by ${test.enabled} enabled when ${test.isStaff} isStaff
|
||||
and ${test.enrollmentMode} enrollment mode`,
|
||||
async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode={test.enrollmentMode}
|
||||
isStaff={test.isStaff}
|
||||
enabled={test.enabled}
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
if (test.isVisible) {
|
||||
expect(chat).toBeInTheDocument();
|
||||
} else {
|
||||
expect(chat).not.toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('if course end date has passed, component should not be visible', async () => {
|
||||
store = await initializeTestStore({
|
||||
specialExams: {
|
||||
activeAttempt: {
|
||||
attempt_id: 1,
|
||||
},
|
||||
},
|
||||
courseMetadata: Factory.build('courseMetadata', {
|
||||
start: '2014-02-03T05:00:00Z',
|
||||
end: '2014-02-05T05:00:00Z',
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode="verified"
|
||||
isStaff
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
expect(chat).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('if learner has active exam attempt, component should not be visible', async () => {
|
||||
store = await initializeTestStore({
|
||||
specialExams: {
|
||||
activeAttempt: {
|
||||
attempt_id: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode="verified"
|
||||
isStaff
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
expect(chat).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays component for audit learner if explicitly enabled', async () => {
|
||||
getConfig.mockImplementation(() => ({ ENABLE_XPERT_AUDIT: true }));
|
||||
|
||||
store = await initializeTestStore({
|
||||
courseMetadata: Factory.build('courseMetadata', {
|
||||
access_expiration: { expiration_date: '' },
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode="audit"
|
||||
isStaff={false}
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
expect(chat).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display component for audit learner if access deadline has passed', async () => {
|
||||
getConfig.mockImplementation(() => ({ ENABLE_XPERT_AUDIT: true }));
|
||||
|
||||
store = await initializeTestStore({
|
||||
courseMetadata: Factory.build('courseMetadata', {
|
||||
access_expiration: { expiration_date: '2014-02-03T05:00:00Z' },
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode="audit"
|
||||
isStaff={false}
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
expect(chat).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Chat';
|
||||
51
src/plugin-slots/CourseTabLinksSlot/README.md
Normal file
51
src/plugin-slots/CourseTabLinksSlot/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Course Tab Links Slot
|
||||
|
||||
### Slot ID: `org.openedx.frontend.learning.course_tab_links.v1`
|
||||
|
||||
### Props:
|
||||
* `activeTabSlug`: The slug of the currently active tab.
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the course tabs.
|
||||
|
||||
## Example
|
||||
|
||||
### Added link to Course Tabs
|
||||

|
||||
|
||||
The following `env.config.jsx` will add a new course tab call "Custom Tab".
|
||||
|
||||
```js
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
import { CourseTabLink } from '@src/course-tabs/CourseTabLink';
|
||||
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
"org.openedx.frontend.learning.course_tab_links.v1": {
|
||||
keepDefault: true,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_tab',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: ({ activeTabSlug })=> (
|
||||
<CourseTabLink
|
||||
url="/some/path"
|
||||
slug="custom-link"
|
||||
title="Custom Tab"
|
||||
activeTabSlug={activeTabSlug}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
BIN
src/plugin-slots/CourseTabLinksSlot/course-tabs-custom.png
Normal file
BIN
src/plugin-slots/CourseTabLinksSlot/course-tabs-custom.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
21
src/plugin-slots/CourseTabLinksSlot/index.tsx
Normal file
21
src/plugin-slots/CourseTabLinksSlot/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { CourseTabLinksList } from '@src/course-tabs/CourseTabLinksList';
|
||||
import React from 'react';
|
||||
|
||||
type CourseTabList = Array<{
|
||||
title: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
export const CourseTabLinksSlot = ({ tabs, activeTabSlug }: {
|
||||
tabs: CourseTabList,
|
||||
activeTabSlug?: string
|
||||
}) => (
|
||||
<PluginSlot
|
||||
id="org.openedx.frontend.learning.course_tab_links.v1"
|
||||
pluginProps={{ activeTabSlug }}
|
||||
>
|
||||
<CourseTabLinksList tabs={tabs} activeTabSlug={activeTabSlug} />
|
||||
</PluginSlot>
|
||||
);
|
||||
28
src/plugin-slots/LearnerToolsSlot/README.md
Normal file
28
src/plugin-slots/LearnerToolsSlot/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Learner Tools Slot
|
||||
|
||||
### Slot ID: `org.openedx.frontend.learning.learner_tools.v1`
|
||||
|
||||
### Slot ID Aliases
|
||||
* `learner_tools_slot`
|
||||
|
||||
### Description
|
||||
This plugin slot provides a location for learner-facing tools and features to be displayed during course content navigation. The slot is rendered via a React portal to `document.body` to ensure proper positioning and stacking context.
|
||||
|
||||
### Props:
|
||||
* `courseId` - The unique identifier for the current course
|
||||
* `unitId` - The unique identifier for the current unit/vertical being viewed
|
||||
* `userId` - The authenticated user's ID (automatically retrieved from auth context)
|
||||
* `isStaff` - Boolean indicating whether the user has staff/instructor privileges
|
||||
* `enrollmentMode` - The user's enrollment mode (e.g., 'audit', 'verified', 'honor', etc.)
|
||||
|
||||
### Usage
|
||||
Plugins registered to this slot can use the provided context to:
|
||||
- Display course-specific tools based on courseId and unitId
|
||||
- Show different features based on user's enrollment mode
|
||||
- Provide staff-only functionality when isStaff is true
|
||||
- Query additional data from Redux store or backend APIs as needed
|
||||
|
||||
### Notes
|
||||
- Returns `null` if user is not authenticated
|
||||
- Plugins should manage their own feature flag checks and requirements
|
||||
- The slot uses a portal to render to `document.body` for flexible positioning
|
||||
47
src/plugin-slots/LearnerToolsSlot/index.jsx
Normal file
47
src/plugin-slots/LearnerToolsSlot/index.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
export const LearnerToolsSlot = ({
|
||||
enrollmentMode = null,
|
||||
isStaff,
|
||||
courseId,
|
||||
unitId,
|
||||
}) => {
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
|
||||
// Return null if user is not authenticated to avoid destructuring errors
|
||||
if (!authenticatedUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { userId } = authenticatedUser;
|
||||
|
||||
// Provide minimal, generic context - no feature-specific flags
|
||||
const pluginContext = {
|
||||
courseId,
|
||||
unitId,
|
||||
userId,
|
||||
isStaff,
|
||||
enrollmentMode,
|
||||
};
|
||||
|
||||
// Use generic plugin slot ID (location-based, not feature-specific)
|
||||
// Plugins will query their own requirements from Redux/config
|
||||
return createPortal(
|
||||
<PluginSlot
|
||||
id="org.openedx.frontend.learning.learner_tools.v1"
|
||||
idAliases={['learner_tools_slot']}
|
||||
pluginProps={pluginContext}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
LearnerToolsSlot.propTypes = {
|
||||
isStaff: PropTypes.bool.isRequired,
|
||||
enrollmentMode: PropTypes.string,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
};
|
||||
104
src/plugin-slots/LearnerToolsSlot/index.test.jsx
Normal file
104
src/plugin-slots/LearnerToolsSlot/index.test.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import * as auth from '@edx/frontend-platform/auth';
|
||||
|
||||
import { LearnerToolsSlot } from './index';
|
||||
|
||||
jest.mock('@openedx/frontend-plugin-framework', () => ({
|
||||
PluginSlot: jest.fn(() => <div data-testid="plugin-slot">Plugin Slot</div>),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('LearnerToolsSlot', () => {
|
||||
const defaultProps = {
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@unit1',
|
||||
isStaff: false,
|
||||
enrollmentMode: 'verified',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Mock document.body for createPortal
|
||||
document.body.innerHTML = '<div id="root"></div>';
|
||||
});
|
||||
|
||||
it('renders PluginSlot with correct props when user is authenticated', () => {
|
||||
const mockUser = { userId: 123, username: 'testuser' };
|
||||
auth.getAuthenticatedUser.mockReturnValue(mockUser);
|
||||
|
||||
render(<LearnerToolsSlot {...defaultProps} />);
|
||||
|
||||
expect(PluginSlot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'org.openedx.frontend.learning.learner_tools.v1',
|
||||
idAliases: ['learner_tools_slot'],
|
||||
pluginProps: {
|
||||
courseId: defaultProps.courseId,
|
||||
unitId: defaultProps.unitId,
|
||||
userId: mockUser.userId,
|
||||
isStaff: defaultProps.isStaff,
|
||||
enrollmentMode: defaultProps.enrollmentMode,
|
||||
},
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when user is not authenticated', () => {
|
||||
auth.getAuthenticatedUser.mockReturnValue(null);
|
||||
|
||||
const { container } = render(<LearnerToolsSlot {...defaultProps} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(PluginSlot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses default null for enrollmentMode when not provided', () => {
|
||||
const mockUser = { userId: 456, username: 'testuser2' };
|
||||
auth.getAuthenticatedUser.mockReturnValue(mockUser);
|
||||
|
||||
const { enrollmentMode, ...propsWithoutEnrollmentMode } = defaultProps;
|
||||
|
||||
render(<LearnerToolsSlot {...propsWithoutEnrollmentMode} />);
|
||||
|
||||
expect(PluginSlot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginProps: expect.objectContaining({
|
||||
enrollmentMode: null,
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('passes isStaff=true correctly', () => {
|
||||
const mockUser = { userId: 789, username: 'staffuser' };
|
||||
auth.getAuthenticatedUser.mockReturnValue(mockUser);
|
||||
|
||||
render(<LearnerToolsSlot {...defaultProps} isStaff />);
|
||||
|
||||
expect(PluginSlot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginProps: expect.objectContaining({
|
||||
isStaff: true,
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('renders to document.body via portal', () => {
|
||||
const mockUser = { userId: 999, username: 'portaluser' };
|
||||
auth.getAuthenticatedUser.mockReturnValue(mockUser);
|
||||
|
||||
render(<LearnerToolsSlot {...defaultProps} />);
|
||||
|
||||
// The portal should render to document.body
|
||||
expect(document.body.querySelector('[data-testid="plugin-slot"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
* [`org.openedx.frontend.learning.course_outline_tab_notifications.v1`](./CourseOutlineTabNotificationsSlot/)
|
||||
* [`org.openedx.frontend.learning.course_recommendations.v1`](./CourseRecommendationsSlot/)
|
||||
* [`org.openedx.frontend.learning.gated_unit_content_message.v1`](./GatedUnitContentMessageSlot/)
|
||||
* [`org.openedx.frontend.learning.learner_tools.v1`](./LearnerToolsSlot/)
|
||||
* [`org.openedx.frontend.learning.next_unit_top_nav_trigger.v1`](./NextUnitTopNavTriggerSlot/)
|
||||
* [`org.openedx.frontend.learning.notification_tray.v1`](./NotificationTraySlot/)
|
||||
* [`org.openedx.frontend.learning.notification_widget.v1`](./NotificationWidgetSlot/)
|
||||
|
||||
Reference in New Issue
Block a user