diff --git a/.env b/.env
index 0775a94fdc..b0129d312a 100644
--- a/.env
+++ b/.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=''
diff --git a/.env.development b/.env.development
index 42338c1c95..8d4aa55baf 100644
--- a/.env.development
+++ b/.env.development
@@ -5,7 +5,7 @@ NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
APP_ID='learning'
-BASE_URL='http://localhost:2000'
+BASE_URL='http://localhost:2010'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course'
@@ -31,7 +31,7 @@ LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
-PORT=2000
+PORT=2010
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
@@ -53,6 +53,6 @@ CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
+ENABLE_XPERT_AUDIT='true'
# Fallback in local style files
PARAGON_THEME_URLS={}
-FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'
diff --git a/src/courseware/course/chat/Chat.jsx b/src/courseware/course/chat/Chat.jsx
index 98c348f312..32fe9c8c14 100644
--- a/src/courseware/course/chat/Chat.jsx
+++ b/src/courseware/course/chat/Chat.jsx
@@ -1,66 +1,37 @@
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';
+import { PluginSlot } from '@openedx/frontend-plugin-framework';
+import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
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.
+ const { userId } = getAuthenticatedUser();
+
+ // If chat is disabled, don't show anything
+ if (!enabled) {
+ return null;
+ }
+
+ // 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(
- ,
document.body,
);
@@ -71,7 +42,6 @@ Chat.propTypes = {
enabled: PropTypes.bool.isRequired,
enrollmentMode: PropTypes.string,
courseId: PropTypes.string.isRequired,
- contentToolsEnabled: PropTypes.bool.isRequired,
unitId: PropTypes.string.isRequired,
};
diff --git a/src/courseware/course/chat/Chat.test.jsx b/src/courseware/course/chat/Chat.test.jsx
index d50ead1adf..838ea2bcb2 100644
--- a/src/courseware/course/chat/Chat.test.jsx
+++ b/src/courseware/course/chat/Chat.test.jsx
@@ -1,286 +1,70 @@
-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 { screen, render } from '@testing-library/react';
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: () => (
mocked Xpert
),
- };
-});
-
-jest.mock('@edx/frontend-platform', () => ({
- getConfig: jest.fn().mockReturnValue({ ENABLE_XPERT_AUDIT: false }),
+// Mock getAuthenticatedUser to provide a stable userId
+jest.mock('@edx/frontend-platform/auth', () => ({
+ getAuthenticatedUser: () => ({ userId: 'user-123' }),
}));
-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(
-
-
- ,
- { store },
- );
-
- const chat = screen.queryByTestId(mockXpertTestId);
- if (test.isVisible) {
- expect(chat).toBeInTheDocument();
- } else {
- expect(chat).not.toBeInTheDocument();
- }
- },
- );
- });
+// Capture props passed into PluginSlot for assertions
+const mockPluginSlotSpy = jest.fn(() => null);
+jest.mock('@openedx/frontend-plugin-framework', () => ({
+ // Provide a minimal mock PluginSlot component that records props
+ PluginSlot: (props) => {
+ mockPluginSlotSpy(props);
+ return ;
+ },
+}));
- // 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(
-
-
- ,
- { store },
- );
+describe('Chat component', () => {
+ const baseProps = {
+ enabled: true,
+ enrollmentMode: 'audit',
+ isStaff: false,
+ courseId: 'course-v1:edX+DemoX+2024',
+ unitId: 'block-v1:edX+DemoX+2024+type@sequential+block@seq123',
+ };
- const chat = screen.queryByTestId(mockXpertTestId);
- if (test.isVisible) {
- expect(chat).toBeInTheDocument();
- } else {
- expect(chat).not.toBeInTheDocument();
- }
- });
+ beforeEach(() => {
+ mockPluginSlotSpy.mockClear();
});
- // 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(
-
-
- ,
- { store },
- );
-
- const chat = screen.queryByTestId(mockXpertTestId);
- if (test.isVisible) {
- expect(chat).toBeInTheDocument();
- } else {
- expect(chat).not.toBeInTheDocument();
- }
- },
- );
+ it('renders nothing when disabled', () => {
+ const { container } = render();
+ // When disabled we expect null render; container firstChild should be null
+ expect(container.firstChild).toBeNull();
+ expect(mockPluginSlotSpy).not.toHaveBeenCalled();
});
- 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(
-
-
- ,
- { store },
- );
-
- const chat = screen.queryByTestId(mockXpertTestId);
- expect(chat).not.toBeInTheDocument();
+ it('renders PluginSlot via portal when enabled', () => {
+ render();
+ // Our mock PluginSlot renders a marker div in document.body via portal
+ expect(screen.getByTestId('plugin-slot-mock')).toBeInTheDocument();
+ expect(mockPluginSlotSpy).toHaveBeenCalledTimes(1);
});
- it('if learner has active exam attempt, component should not be visible', async () => {
- store = await initializeTestStore({
- specialExams: {
- activeAttempt: {
- attempt_id: 1,
- },
- },
+ it('passes correct pluginProps to PluginSlot', () => {
+ render();
+ const call = mockPluginSlotSpy.mock.calls[0][0];
+ expect(call.id).toBe('learner_tools_slot');
+ expect(call.pluginProps).toEqual({
+ courseId: baseProps.courseId,
+ unitId: baseProps.unitId,
+ userId: 'user-123',
+ isStaff: baseProps.isStaff,
+ enrollmentMode: baseProps.enrollmentMode,
});
-
- render(
-
-
- ,
- { 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(
-
-
- ,
- { store },
- );
-
- const chat = screen.queryByTestId(mockXpertTestId);
- expect(chat).toBeInTheDocument();
+ it('allows null enrollmentMode (omitted prop) and still passes context', () => {
+ render();
+ const call = mockPluginSlotSpy.mock.calls[0][0];
+ expect(call.pluginProps.enrollmentMode).toBeNull();
});
- 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(
-
-
- ,
- { store },
- );
-
- const chat = screen.queryByTestId(mockXpertTestId);
- expect(chat).not.toBeInTheDocument();
+ it('propagates isStaff=true correctly', () => {
+ render();
+ const call = mockPluginSlotSpy.mock.calls[0][0];
+ expect(call.pluginProps.isStaff).toBe(true);
});
});