From fa32637f2ad1aa7f5a2e059d8e4a8277f76a99b2 Mon Sep 17 00:00:00 2001 From: Maniraja Raman Date: Tue, 21 Oct 2025 06:52:14 +0000 Subject: [PATCH] feat: update chat component to use PluginSlot and simplify logic --- .env | 1 - .env.development | 6 +- src/courseware/course/chat/Chat.jsx | 76 ++---- src/courseware/course/chat/Chat.test.jsx | 318 ++++------------------- 4 files changed, 77 insertions(+), 324 deletions(-) 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); }); });