From b282bc05df39faee448127a821991eac9ed907f7 Mon Sep 17 00:00:00 2001 From: Maniraja Raman Date: Wed, 19 Nov 2025 08:49:30 +0530 Subject: [PATCH] feat: update chat component to use PluginSlot and simplify logic (#1810) --- .env | 1 - .env.development | 1 - package-lock.json | 8 +- package.json | 2 +- src/courseware/course/Course.jsx | 22 +- src/courseware/course/Course.test.jsx | 45 +-- src/courseware/course/chat/Chat.jsx | 82 ----- src/courseware/course/chat/Chat.test.jsx | 286 ------------------ src/courseware/course/chat/index.js | 1 - src/plugin-slots/LearnerToolsSlot/README.md | 28 ++ src/plugin-slots/LearnerToolsSlot/index.jsx | 47 +++ .../LearnerToolsSlot/index.test.jsx | 104 +++++++ src/plugin-slots/README.md | 1 + 13 files changed, 220 insertions(+), 408 deletions(-) delete mode 100644 src/courseware/course/chat/Chat.jsx delete mode 100644 src/courseware/course/chat/Chat.test.jsx delete mode 100644 src/courseware/course/chat/index.js create mode 100644 src/plugin-slots/LearnerToolsSlot/README.md create mode 100644 src/plugin-slots/LearnerToolsSlot/index.jsx create mode 100644 src/plugin-slots/LearnerToolsSlot/index.test.jsx diff --git a/.env b/.env index 0775a94f..b0129d31 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 42338c1c..7f9cdfc7 100644 --- a/.env.development +++ b/.env.development @@ -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' diff --git a/package-lock.json b/package-lock.json index 2a0937b2..80188f7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 40df415a..b1142f36 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index e035f529..c8200cdf 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -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 && ( - <> - - + {shouldDisplayLearnerTools && ( + )}
diff --git a/src/courseware/course/Course.test.jsx b/src/courseware/course/Course.test.jsx index 744bebe5..754ff134 100644 --- a/src/courseware/course/Course.test.jsx +++ b/src/courseware/course/Course.test.jsx @@ -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 }) =>
{children}
, + }; +}); +const mockLearnerToolsTestId = 'fake-learner-tools'; jest.mock( - './chat/Chat', - // eslint-disable-next-line react/prop-types - () => function ({ courseId }) { - return
Chat contents {courseId}
; - }, + '../../plugin-slots/LearnerToolsSlot', + () => ({ + // eslint-disable-next-line react/prop-types + LearnerToolsSlot({ courseId }) { + return
LearnerTools contents {courseId}
; + }, + }), ); 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(, { 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(, { store: testStore, wrapWithRouter: true }); - const chat = screen.queryByTestId(mockChatTestId); - await expect(chat).not.toBeInTheDocument(); + const learnerTools = screen.queryByTestId(mockLearnerToolsTestId); + await expect(learnerTools).not.toBeInTheDocument(); }); }); diff --git a/src/courseware/course/chat/Chat.jsx b/src/courseware/course/chat/Chat.jsx deleted file mode 100644 index 98c348f3..00000000 --- a/src/courseware/course/chat/Chat.jsx +++ /dev/null @@ -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( - , - 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; diff --git a/src/courseware/course/chat/Chat.test.jsx b/src/courseware/course/chat/Chat.test.jsx deleted file mode 100644 index d50ead1a..00000000 --- a/src/courseware/course/chat/Chat.test.jsx +++ /dev/null @@ -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: () => (
mocked Xpert
), - }; -}); - -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( - - - , - { 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( - - - , - { 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( - - - , - { 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( - - - , - { 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( - - - , - { 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('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(); - }); -}); diff --git a/src/courseware/course/chat/index.js b/src/courseware/course/chat/index.js deleted file mode 100644 index 654497b6..00000000 --- a/src/courseware/course/chat/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Chat'; diff --git a/src/plugin-slots/LearnerToolsSlot/README.md b/src/plugin-slots/LearnerToolsSlot/README.md new file mode 100644 index 00000000..16c543e5 --- /dev/null +++ b/src/plugin-slots/LearnerToolsSlot/README.md @@ -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 diff --git a/src/plugin-slots/LearnerToolsSlot/index.jsx b/src/plugin-slots/LearnerToolsSlot/index.jsx new file mode 100644 index 00000000..a9cf1d50 --- /dev/null +++ b/src/plugin-slots/LearnerToolsSlot/index.jsx @@ -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( + , + document.body, + ); +}; + +LearnerToolsSlot.propTypes = { + isStaff: PropTypes.bool.isRequired, + enrollmentMode: PropTypes.string, + courseId: PropTypes.string.isRequired, + unitId: PropTypes.string.isRequired, +}; diff --git a/src/plugin-slots/LearnerToolsSlot/index.test.jsx b/src/plugin-slots/LearnerToolsSlot/index.test.jsx new file mode 100644 index 00000000..45091e41 --- /dev/null +++ b/src/plugin-slots/LearnerToolsSlot/index.test.jsx @@ -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(() =>
Plugin Slot
), +})); + +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 = '
'; + }); + + it('renders PluginSlot with correct props when user is authenticated', () => { + const mockUser = { userId: 123, username: 'testuser' }; + auth.getAuthenticatedUser.mockReturnValue(mockUser); + + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + // The portal should render to document.body + expect(document.body.querySelector('[data-testid="plugin-slot"]')).toBeInTheDocument(); + }); +}); diff --git a/src/plugin-slots/README.md b/src/plugin-slots/README.md index be410164..045d9a76 100644 --- a/src/plugin-slots/README.md +++ b/src/plugin-slots/README.md @@ -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/)