feat: update chat component to use PluginSlot and simplify logic (#1810)

This commit is contained in:
Maniraja Raman
2025-11-19 08:49:30 +05:30
committed by GitHub
parent d987aed861
commit b282bc05df
13 changed files with 220 additions and 408 deletions

1
.env
View File

@@ -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=''

View File

@@ -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'

8
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 />

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -1 +0,0 @@
export { default } from './Chat';

View 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

View 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,
};

View 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();
});
});

View File

@@ -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/)