Skip to content

Commit 7affcf0

Browse files
committed
feat: Add slots to add tab links and add mechanism for plugin routes
1 parent ddbc212 commit 7affcf0

File tree

9 files changed

+211
-31
lines changed

9 files changed

+211
-31
lines changed
Lines changed: 20 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
import React from 'react';
2-
import PropTypes from 'prop-types';
3-
import { useIntl } from '@edx/frontend-platform/i18n';
42
import classNames from 'classnames';
5-
6-
import messages from './messages';
7-
import Tabs from '../generic/tabs/Tabs';
3+
import { useIntl } from '@edx/frontend-platform/i18n';
4+
import { CourseTabLinksSlot } from '../plugin-slots/CourseTabLinksSlot';
85
import { CoursewareSearch, CoursewareSearchToggle } from '../course-home/courseware-search';
96
import { useCoursewareSearchState } from '../course-home/courseware-search/hooks';
107

8+
import Tabs from '../generic/tabs/Tabs';
9+
import messages from './messages';
10+
11+
interface CourseTabsNavigationProps {
12+
activeTabSlug?: string;
13+
className?: string | null;
14+
tabs: Array<{
15+
title: string;
16+
slug: string;
17+
url: string;
18+
}>;
19+
}
20+
1121
const CourseTabsNavigation = ({
12-
activeTabSlug, className, tabs,
13-
}) => {
22+
activeTabSlug = undefined,
23+
className = null,
24+
tabs,
25+
}:CourseTabsNavigationProps) => {
1426
const intl = useIntl();
1527
const { show } = useCoursewareSearchState();
1628

@@ -23,15 +35,7 @@ const CourseTabsNavigation = ({
2335
className="nav-underline-tabs"
2436
aria-label={intl.formatMessage(messages.courseMaterial)}
2537
>
26-
{tabs.map(({ url, title, slug }) => (
27-
<a
28-
key={slug}
29-
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
30-
href={url}
31-
>
32-
{title}
33-
</a>
34-
))}
38+
<CourseTabLinksSlot tabs={tabs} activeTabSlug={activeTabSlug} />
3539
</Tabs>
3640
</div>
3741
<div className="search-toggle">
@@ -44,19 +48,4 @@ const CourseTabsNavigation = ({
4448
);
4549
};
4650

47-
CourseTabsNavigation.propTypes = {
48-
activeTabSlug: PropTypes.string,
49-
className: PropTypes.string,
50-
tabs: PropTypes.arrayOf(PropTypes.shape({
51-
title: PropTypes.string.isRequired,
52-
slug: PropTypes.string.isRequired,
53-
url: PropTypes.string.isRequired,
54-
})).isRequired,
55-
};
56-
57-
CourseTabsNavigation.defaultProps = {
58-
activeTabSlug: undefined,
59-
className: null,
60-
};
61-
6251
export default CourseTabsNavigation;

src/index.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getConfig,
55
} from '@edx/frontend-platform';
66
import { AppProvider, ErrorPage, PageWrap } from '@edx/frontend-platform/react';
7+
import { PluginSlot } from '@openedx/frontend-plugin-framework';
78
import { StrictMode } from 'react';
89
import { createRoot } from 'react-dom/client';
910
import { Routes, Route } from 'react-router-dom';
@@ -23,6 +24,7 @@ import CoursewareRedirectLandingPage from './courseware/CoursewareRedirectLandin
2324
import DatesTab from './course-home/dates-tab';
2425
import GoalUnsubscribe from './course-home/goal-unsubscribe';
2526
import ProgressTab from './course-home/progress-tab/ProgressTab';
27+
import { getPluginRoutes } from './plugin-routes';
2628
import { TabContainer } from './tab-page';
2729

2830
import { fetchDatesTab, fetchOutlineTab, fetchProgressTab } from './course-home/data';
@@ -143,6 +145,7 @@ subscribe(APP_READY, () => {
143145
)}
144146
/>
145147
))}
148+
{getPluginRoutes()}
146149
</Routes>
147150
</div>
148151
</UserMessagesProvider>

src/plugin-routes.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { getConfig } from "@edx/frontend-platform";
2+
import { render, screen } from '@testing-library/react';
3+
import React from 'react';
4+
import { getPluginRoutes } from './plugin-routes';
5+
6+
// Mock dependencies
7+
jest.mock('@edx/frontend-platform', () => ({
8+
getConfig: jest.fn(),
9+
}));
10+
11+
jest.mock('react-router-dom', () => ({
12+
Route: ({ element }: { element: React.ReactNode }) => element,
13+
}));
14+
15+
jest.mock('./decode-page-route', () => ({
16+
__esModule: true,
17+
default: ({ children }: { children: React.ReactNode }) => children,
18+
}));
19+
20+
jest.mock('@openedx/frontend-plugin-framework', () => ({
21+
PluginSlot: ({ id, pluginProps }: { id: string; pluginProps: Record<string, any> }) => (
22+
<div data-testid="plugin-slot" data-route={pluginProps.route}>
23+
id: {id}, route: {pluginProps.route}
24+
</div>
25+
),
26+
}));
27+
28+
describe('getPluginRoutes', () => {
29+
it('should return a valid route element for each plugin route', () => {
30+
const pluginRoutes = ['/route-1', '/route-2'];
31+
(getConfig as jest.Mock).mockImplementation(() => ({
32+
PLUGIN_ROUTES: pluginRoutes,
33+
}));
34+
35+
const result = getPluginRoutes();
36+
const { container } = render(<>{result}</>);
37+
38+
pluginRoutes.forEach((route) => {
39+
expect(container.querySelector(`[data-route="${route}"]`)).toBeInTheDocument();
40+
});
41+
expect(container.querySelectorAll('[data-testid="plugin-slot"]').length).toBe(pluginRoutes.length);
42+
});
43+
44+
it('should return null if no plugin routes are configured', () => {
45+
(getConfig as jest.Mock).mockImplementation(() => ({}));
46+
47+
const result = getPluginRoutes();
48+
expect(result).toBeNull();
49+
});
50+
});

src/plugin-routes.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { getConfig } from '@edx/frontend-platform';
2+
import { Route } from 'react-router-dom';
3+
import { CoursePageSlot } from './plugin-slots/CoursePageSlot';
4+
import DecodePageRoute from './decode-page-route';
5+
6+
export function getPluginRoutes() {
7+
return getConfig()?.PLUGIN_ROUTES?.map((route: string) => (
8+
<Route
9+
key={route}
10+
path={route}
11+
element={(
12+
<DecodePageRoute>
13+
<CoursePageSlot route={route} />
14+
</DecodePageRoute>
15+
)}
16+
/>
17+
)) ?? null;
18+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Course Page
2+
3+
### Slot ID: `org.openedx.frontend.learning.course_page.v1`
4+
5+
### Slot ID Aliases
6+
* `course_page`
7+
8+
### Props:
9+
* `route`
10+
11+
## Description
12+
13+
This slot is used to add new course page to the learning MFE.
14+
15+
16+
## Example
17+
18+
### New static page
19+
20+
The following `env.config.jsx` will create a new URL at `/course/:courseId/test`.
21+
22+
Note that you need to add a `PLUGIN_ROUTES` entry in the config as well that lists all the plugin
23+
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.
24+
25+
```js
26+
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
27+
28+
const config = {
29+
PLUGIN_ROUTES: ["/course/:courseId/test"],
30+
pluginSlots: {
31+
"org.openedx.frontend.learning.course_page.v1": {
32+
plugins: [
33+
{
34+
op: PLUGIN_OPERATIONS.Insert,
35+
widget: {
36+
id: 'custom_tab',
37+
type: DIRECT_PLUGIN,
38+
RenderWidget: ()=> (<h1>Custom Page</h1>),
39+
},
40+
},
41+
],
42+
},
43+
},
44+
}
45+
46+
export default config;
47+
```
48+
49+
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { PluginSlot } from '@openedx/frontend-plugin-framework';
2+
3+
export const CoursePageSlot = ({ route } : { route: string }) => <PluginSlot id="org.openedx.frontend.learning.course_page.v1" pluginProps={{ route }} />;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Course Tab Links Slot
2+
3+
### Slot ID: `org.openedx.frontend.learning.course_tab_links.v1`
4+
5+
## Description
6+
7+
This slot is used to replace/modify/hide the course tabs.
8+
9+
## Example
10+
11+
### Added link to Course Tabs
12+
![Added "Custom Tab" to course tabs](./course-tabs-custom.png)
13+
14+
The following `env.config.jsx` will add a new course tab call "Custom Tab".
15+
16+
```js
17+
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
18+
19+
const config = {
20+
pluginSlots: {
21+
"org.openedx.frontend.learning.course_tab_links.v1": {
22+
keepDefault: true,
23+
plugins: [
24+
{
25+
op: PLUGIN_OPERATIONS.Insert,
26+
widget: {
27+
id: 'custom_tab',
28+
type: DIRECT_PLUGIN,
29+
RenderWidget: ()=> (
30+
<a
31+
className={classNames('nav-item flex-shrink-0 nav-link')}
32+
href="#"
33+
>
34+
Custom Tab
35+
</a>
36+
),
37+
},
38+
},
39+
],
40+
},
41+
},
42+
}
43+
44+
export default config;
45+
```
Loading
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { PluginSlot } from '@openedx/frontend-plugin-framework';
2+
import classNames from 'classnames';
3+
import React from 'react';
4+
5+
type CourseTabList = Array<{
6+
title: string;
7+
slug: string;
8+
url: string;
9+
}>;
10+
11+
export const CourseTabLinksSlot = ({ tabs, activeTabSlug }: { tabs: CourseTabList, activeTabSlug?: string }) => (
12+
<PluginSlot id="org.openedx.frontend.learning.course_tab_links.v1" pluginProps={{ activeTabSlug }}>
13+
{tabs.map(({ url, title, slug }) => (
14+
<a
15+
key={slug}
16+
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
17+
href={url}
18+
>
19+
{title}
20+
</a>
21+
))}
22+
</PluginSlot>
23+
);

0 commit comments

Comments
 (0)