diff --git a/src/course-tabs/CourseTabsNavigation.jsx b/src/course-tabs/CourseTabsNavigation.tsx
similarity index 58%
rename from src/course-tabs/CourseTabsNavigation.jsx
rename to src/course-tabs/CourseTabsNavigation.tsx
index 9c2a12ef8c..87a1b92c4a 100644
--- a/src/course-tabs/CourseTabsNavigation.jsx
+++ b/src/course-tabs/CourseTabsNavigation.tsx
@@ -1,16 +1,28 @@
import React from 'react';
-import PropTypes from 'prop-types';
-import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
-
-import messages from './messages';
-import Tabs from '../generic/tabs/Tabs';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { CourseTabLinksSlot } from '../plugin-slots/CourseTabLinksSlot';
import { CoursewareSearch, CoursewareSearchToggle } from '../course-home/courseware-search';
import { useCoursewareSearchState } from '../course-home/courseware-search/hooks';
+import Tabs from '../generic/tabs/Tabs';
+import messages from './messages';
+
+interface CourseTabsNavigationProps {
+ activeTabSlug?: string;
+ className?: string | null;
+ tabs: Array<{
+ title: string;
+ slug: string;
+ url: string;
+ }>;
+}
+
const CourseTabsNavigation = ({
- activeTabSlug, className, tabs,
-}) => {
+ activeTabSlug = undefined,
+ className = null,
+ tabs,
+}:CourseTabsNavigationProps) => {
const intl = useIntl();
const { show } = useCoursewareSearchState();
@@ -23,15 +35,7 @@ const CourseTabsNavigation = ({
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
>
- {tabs.map(({ url, title, slug }) => (
-
- {title}
-
- ))}
+
@@ -44,19 +48,4 @@ const CourseTabsNavigation = ({
);
};
-CourseTabsNavigation.propTypes = {
- activeTabSlug: PropTypes.string,
- className: PropTypes.string,
- tabs: PropTypes.arrayOf(PropTypes.shape({
- title: PropTypes.string.isRequired,
- slug: PropTypes.string.isRequired,
- url: PropTypes.string.isRequired,
- })).isRequired,
-};
-
-CourseTabsNavigation.defaultProps = {
- activeTabSlug: undefined,
- className: null,
-};
-
export default CourseTabsNavigation;
diff --git a/src/index.jsx b/src/index.jsx
index b3748ca688..bdddfc6aad 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -23,6 +23,7 @@ import CoursewareRedirectLandingPage from './courseware/CoursewareRedirectLandin
import DatesTab from './course-home/dates-tab';
import GoalUnsubscribe from './course-home/goal-unsubscribe';
import ProgressTab from './course-home/progress-tab/ProgressTab';
+import { getPluginRoutes } from './plugin-routes';
import { TabContainer } from './tab-page';
import { fetchDatesTab, fetchOutlineTab, fetchProgressTab } from './course-home/data';
@@ -143,6 +144,7 @@ subscribe(APP_READY, () => {
)}
/>
))}
+ {getPluginRoutes()}
diff --git a/src/plugin-routes.test.tsx b/src/plugin-routes.test.tsx
new file mode 100644
index 0000000000..102fa3da45
--- /dev/null
+++ b/src/plugin-routes.test.tsx
@@ -0,0 +1,52 @@
+import { getConfig } from '@edx/frontend-platform';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { getPluginRoutes } from './plugin-routes';
+
+// Mock dependencies
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(),
+}));
+
+jest.mock('react-router-dom', () => ({
+ Route: ({ element }: { element: React.ReactNode }) => element,
+}));
+
+jest.mock('./decode-page-route', () => ({
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+jest.mock('@openedx/frontend-plugin-framework', () => ({
+ PluginSlot: ({ id }: { id: string }) => (
+
+ id: {id}
+
+ ),
+}));
+
+describe('getPluginRoutes', () => {
+ it('should return a valid route element for each plugin route', () => {
+ const pluginRoutes = [
+ { id: 'route-1', route: '/route-1' },
+ { id: 'route-2', route: '/route-2' },
+ ];
+ (getConfig as jest.Mock).mockImplementation(() => ({
+ PLUGIN_ROUTES: pluginRoutes,
+ }));
+
+ const { container } = render(getPluginRoutes());
+
+ pluginRoutes.forEach(({ id }) => {
+ expect(container.querySelector(`[data-plugin-id="org.openedx.frontend.learning.course_page.${id}.v1"]`)).toBeInTheDocument();
+ });
+ expect(container.querySelectorAll('[data-testid="plugin-slot"]').length).toBe(pluginRoutes.length);
+ });
+
+ it('should return null if no plugin routes are configured', () => {
+ (getConfig as jest.Mock).mockImplementation(() => ([]));
+
+ const result = getPluginRoutes();
+ expect(result).toBeNull();
+ });
+});
diff --git a/src/plugin-routes.tsx b/src/plugin-routes.tsx
new file mode 100644
index 0000000000..2c95750f26
--- /dev/null
+++ b/src/plugin-routes.tsx
@@ -0,0 +1,23 @@
+import { getConfig } from '@edx/frontend-platform';
+import { Route } from 'react-router-dom';
+import { CoursePageSlot } from './plugin-slots/CoursePageSlot';
+import DecodePageRoute from './decode-page-route';
+
+type PluginRoute = {
+ id: string,
+ route: string,
+};
+
+export function getPluginRoutes() {
+ return (getConfig()?.PLUGIN_ROUTES as PluginRoute[])?.map(({ route, id }) => (
+
+
+
+ )}
+ />
+ )) ?? null;
+}
diff --git a/src/plugin-slots/CoursePageSlot/README.md b/src/plugin-slots/CoursePageSlot/README.md
new file mode 100644
index 0000000000..cb1d8cbff6
--- /dev/null
+++ b/src/plugin-slots/CoursePageSlot/README.md
@@ -0,0 +1,50 @@
+# Course Page
+
+### Slot ID: `org.openedx.frontend.learning.course_page..v1`
+
+## Description
+
+This slot is used to add a new course page to the learning MFE.
+
+
+## Example
+
+### New static page
+
+The following `env.config.jsx` will create a new URL at `/course/:courseId/test`.
+
+Note that you need to add a `PLUGIN_ROUTES` entry in the config as well that lists all the plugin
+routes that the plugins need. A plugin will be passed this route as a prop and can match and display
+its content only when the route matches.
+
+`PLUGIN_ROUTES` should have a list of objects, each having a entry for `id` and `route`. Here the
+`id` should uniquely identify the page, and the route should be the react-router compatible path.
+The `id` will also form part of the slot name, for instance if you have a route with an id of
+`more-info`, the slot for that page will be `org.openedx.frontend.learning.course_page.more-info.v1`
+
+
+```js
+import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
+
+const config = {
+ PLUGIN_ROUTES: [{ id: 'test', route: '/course/:courseId/test' }],
+ pluginSlots: {
+ "org.openedx.frontend.learning.course_page.test.v1": {
+ plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'custom_tab',
+ type: DIRECT_PLUGIN,
+ RenderWidget: ()=> (Custom Page
),
+ },
+ },
+ ],
+ },
+ },
+}
+
+export default config;
+```
+
+
diff --git a/src/plugin-slots/CoursePageSlot/index.tsx b/src/plugin-slots/CoursePageSlot/index.tsx
new file mode 100644
index 0000000000..e82c711cf5
--- /dev/null
+++ b/src/plugin-slots/CoursePageSlot/index.tsx
@@ -0,0 +1,5 @@
+import { PluginSlot } from '@openedx/frontend-plugin-framework';
+
+export const CoursePageSlot = ({ pageId } : { pageId: string }) => (
+
+);
diff --git a/src/plugin-slots/CourseTabLinksSlot/README.md b/src/plugin-slots/CourseTabLinksSlot/README.md
new file mode 100644
index 0000000000..692135e5dc
--- /dev/null
+++ b/src/plugin-slots/CourseTabLinksSlot/README.md
@@ -0,0 +1,45 @@
+# Course Tab Links Slot
+
+### Slot ID: `org.openedx.frontend.learning.course_tab_links.v1`
+
+## Description
+
+This slot is used to replace/modify/hide the course tabs.
+
+## Example
+
+### Added link to Course Tabs
+
+
+The following `env.config.jsx` will add a new course tab call "Custom Tab".
+
+```js
+import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
+
+const config = {
+ pluginSlots: {
+ "org.openedx.frontend.learning.course_tab_links.v1": {
+ keepDefault: true,
+ plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'custom_tab',
+ type: DIRECT_PLUGIN,
+ RenderWidget: ()=> (
+
+ Custom Tab
+
+ ),
+ },
+ },
+ ],
+ },
+ },
+}
+
+export default config;
+```
diff --git a/src/plugin-slots/CourseTabLinksSlot/course-tabs-custom.png b/src/plugin-slots/CourseTabLinksSlot/course-tabs-custom.png
new file mode 100644
index 0000000000..4fcdf3add5
Binary files /dev/null and b/src/plugin-slots/CourseTabLinksSlot/course-tabs-custom.png differ
diff --git a/src/plugin-slots/CourseTabLinksSlot/index.tsx b/src/plugin-slots/CourseTabLinksSlot/index.tsx
new file mode 100644
index 0000000000..c8157623e6
--- /dev/null
+++ b/src/plugin-slots/CourseTabLinksSlot/index.tsx
@@ -0,0 +1,23 @@
+import { PluginSlot } from '@openedx/frontend-plugin-framework';
+import classNames from 'classnames';
+import React from 'react';
+
+type CourseTabList = Array<{
+ title: string;
+ slug: string;
+ url: string;
+}>;
+
+export const CourseTabLinksSlot = ({ tabs, activeTabSlug }: { tabs: CourseTabList, activeTabSlug?: string }) => (
+
+ {tabs.map(({ url, title, slug }) => (
+
+ {title}
+
+ ))}
+
+);