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