feat: update chat component to use PluginSlot and simplify logic (#1810)
This commit is contained in:
1
.env
1
.env
@@ -53,4 +53,3 @@ OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
|||||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||||
# Fallback in local style files
|
# Fallback in local style files
|
||||||
PARAGON_THEME_URLS={}
|
PARAGON_THEME_URLS={}
|
||||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT=''
|
|
||||||
|
|||||||
@@ -55,4 +55,3 @@ OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
|||||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||||
# Fallback in local style files
|
# Fallback in local style files
|
||||||
PARAGON_THEME_URLS={}
|
PARAGON_THEME_URLS={}
|
||||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'
|
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -13,7 +13,7 @@
|
|||||||
"@edx/browserslist-config": "1.5.0",
|
"@edx/browserslist-config": "1.5.0",
|
||||||
"@edx/frontend-component-footer": "^14.6.0",
|
"@edx/frontend-component-footer": "^14.6.0",
|
||||||
"@edx/frontend-component-header": "^8.0.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-lib-special-exams": "^4.0.0",
|
||||||
"@edx/frontend-platform": "^8.4.0",
|
"@edx/frontend-platform": "^8.4.0",
|
||||||
"@edx/openedx-atlas": "^0.7.0",
|
"@edx/openedx-atlas": "^0.7.0",
|
||||||
@@ -2379,9 +2379,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@edx/frontend-lib-learning-assistant": {
|
"node_modules/@edx/frontend-lib-learning-assistant": {
|
||||||
"version": "2.23.1",
|
"version": "2.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-2.23.1.tgz",
|
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-2.24.0.tgz",
|
||||||
"integrity": "sha512-0rDHlE3tlADWOcqKaVIKkMK2YGonbRaYJfmBSgH+Sn6+BFg2e541fn7NC9e5rIaiV1BnMREF7dxyRa/IEYLZLA==",
|
"integrity": "sha512-+RwmKbYxsJ6Ct9scBX3jnxSUuoiW5ed1vbCz9PQiQ8fobuiMM3fokLynIreB5ZVYWvrjSa5OaMwBq1bUXsprZw==",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"@edx/browserslist-config": "1.5.0",
|
"@edx/browserslist-config": "1.5.0",
|
||||||
"@edx/frontend-component-footer": "^14.6.0",
|
"@edx/frontend-component-footer": "^14.6.0",
|
||||||
"@edx/frontend-component-header": "^8.0.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-lib-special-exams": "^4.0.0",
|
||||||
"@edx/frontend-platform": "^8.4.0",
|
"@edx/frontend-platform": "^8.4.0",
|
||||||
"@edx/openedx-atlas": "^0.7.0",
|
"@edx/openedx-atlas": "^0.7.0",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { breakpoints, useWindowSize } from '@openedx/paragon';
|
|||||||
|
|
||||||
import { AlertList } from '@src/generic/user-messages';
|
import { AlertList } from '@src/generic/user-messages';
|
||||||
import { useModel } from '@src/generic/model-store';
|
import { useModel } from '@src/generic/model-store';
|
||||||
import Chat from './chat/Chat';
|
import { LearnerToolsSlot } from '../../plugin-slots/LearnerToolsSlot';
|
||||||
import SidebarProvider from './sidebar/SidebarContextProvider';
|
import SidebarProvider from './sidebar/SidebarContextProvider';
|
||||||
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
|
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
|
||||||
import { NotificationsDiscussionsSidebarTriggerSlot } from '../../plugin-slots/NotificationsDiscussionsSidebarTriggerSlot';
|
import { NotificationsDiscussionsSidebarTriggerSlot } from '../../plugin-slots/NotificationsDiscussionsSidebarTriggerSlot';
|
||||||
@@ -59,7 +59,7 @@ const Course = ({
|
|||||||
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
|
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
|
||||||
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
|
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
|
||||||
);
|
);
|
||||||
const shouldDisplayChat = windowWidth >= breakpoints.medium.minWidth;
|
const shouldDisplayLearnerTools = windowWidth >= breakpoints.medium.minWidth;
|
||||||
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
|
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,17 +88,13 @@ const Course = ({
|
|||||||
isStaff={isStaff}
|
isStaff={isStaff}
|
||||||
unitId={unitId}
|
unitId={unitId}
|
||||||
/>
|
/>
|
||||||
{shouldDisplayChat && (
|
{shouldDisplayLearnerTools && (
|
||||||
<>
|
<LearnerToolsSlot
|
||||||
<Chat
|
enrollmentMode={course.enrollmentMode}
|
||||||
enabled={course.learningAssistantEnabled}
|
isStaff={isStaff}
|
||||||
enrollmentMode={course.enrollmentMode}
|
courseId={courseId}
|
||||||
isStaff={isStaff}
|
unitId={unitId}
|
||||||
courseId={courseId}
|
/>
|
||||||
contentToolsEnabled={course.showCalculator || course.notes.enabled}
|
|
||||||
unitId={unitId}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<div className="w-100 d-flex align-items-center">
|
<div className="w-100 d-flex align-items-center">
|
||||||
<CourseOutlineMobileSidebarTriggerSlot />
|
<CourseOutlineMobileSidebarTriggerSlot />
|
||||||
|
|||||||
@@ -13,17 +13,25 @@ import Course from './Course';
|
|||||||
import setupDiscussionSidebar from './test-utils';
|
import setupDiscussionSidebar from './test-utils';
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/analytics');
|
jest.mock('@edx/frontend-platform/analytics');
|
||||||
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
|
jest.mock('@edx/frontend-lib-special-exams', () => {
|
||||||
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
|
const actual = jest.requireActual('@edx/frontend-lib-special-exams');
|
||||||
checkExamEntry: () => jest.fn(),
|
return {
|
||||||
}));
|
...actual,
|
||||||
const mockChatTestId = 'fake-chat';
|
__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(
|
jest.mock(
|
||||||
'./chat/Chat',
|
'../../plugin-slots/LearnerToolsSlot',
|
||||||
// eslint-disable-next-line react/prop-types
|
() => ({
|
||||||
() => function ({ courseId }) {
|
// eslint-disable-next-line react/prop-types
|
||||||
return <div className="fake-chat" data-testid={mockChatTestId}>Chat contents {courseId} </div>;
|
LearnerToolsSlot({ courseId }) {
|
||||||
},
|
return <div className="fake-learner-tools" data-testid={mockLearnerToolsTestId}>LearnerTools contents {courseId} </div>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const recordFirstSectionCelebration = jest.fn();
|
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', {
|
const courseMetadata = Factory.build('courseMetadata', {
|
||||||
learning_assistant_enabled: true,
|
|
||||||
enrollment: { mode: 'verified' },
|
enrollment: { mode: 'verified' },
|
||||||
});
|
});
|
||||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||||
const { courseware } = testStore.getState();
|
const { courseware, models } = testStore.getState();
|
||||||
const { courseId, sequenceId } = courseware;
|
const { courseId, sequenceId } = courseware;
|
||||||
const testData = {
|
const testData = {
|
||||||
...mockData,
|
...mockData,
|
||||||
courseId,
|
courseId,
|
||||||
sequenceId,
|
sequenceId,
|
||||||
|
unitId: Object.values(models.units)[0].id,
|
||||||
};
|
};
|
||||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
const chat = screen.queryByTestId(mockChatTestId);
|
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
|
||||||
waitFor(() => expect(chat).toBeInTheDocument());
|
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;
|
global.innerWidth = breakpoints.extraSmall.minWidth;
|
||||||
const courseMetadata = Factory.build('courseMetadata', {
|
const courseMetadata = Factory.build('courseMetadata', {
|
||||||
learning_assistant_enabled: true,
|
|
||||||
enrollment: { mode: 'verified' },
|
enrollment: { mode: 'verified' },
|
||||||
});
|
});
|
||||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||||
@@ -393,7 +400,7 @@ describe('Course', () => {
|
|||||||
sequenceId,
|
sequenceId,
|
||||||
};
|
};
|
||||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||||
const chat = screen.queryByTestId(mockChatTestId);
|
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
|
||||||
await expect(chat).not.toBeInTheDocument();
|
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';
|
|
||||||
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_outline_tab_notifications.v1`](./CourseOutlineTabNotificationsSlot/)
|
||||||
* [`org.openedx.frontend.learning.course_recommendations.v1`](./CourseRecommendationsSlot/)
|
* [`org.openedx.frontend.learning.course_recommendations.v1`](./CourseRecommendationsSlot/)
|
||||||
* [`org.openedx.frontend.learning.gated_unit_content_message.v1`](./GatedUnitContentMessageSlot/)
|
* [`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.next_unit_top_nav_trigger.v1`](./NextUnitTopNavTriggerSlot/)
|
||||||
* [`org.openedx.frontend.learning.notification_tray.v1`](./NotificationTraySlot/)
|
* [`org.openedx.frontend.learning.notification_tray.v1`](./NotificationTraySlot/)
|
||||||
* [`org.openedx.frontend.learning.notification_widget.v1`](./NotificationWidgetSlot/)
|
* [`org.openedx.frontend.learning.notification_widget.v1`](./NotificationWidgetSlot/)
|
||||||
|
|||||||
Reference in New Issue
Block a user