Skip to content

Commit 3f48bac

Browse files
committed
feat: Add slots to add tab links and add slots for plugin routes
Adds new slot that allow adding new links to course tabs and a slot that allows creating new routes for additional additonal pages.
1 parent d5140a6 commit 3f48bac

9 files changed

Lines changed: 222 additions & 33 deletions

File tree

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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import CoursewareRedirectLandingPage from './courseware/CoursewareRedirectLandin
2323
import DatesTab from './course-home/dates-tab';
2424
import GoalUnsubscribe from './course-home/goal-unsubscribe';
2525
import ProgressTab from './course-home/progress-tab/ProgressTab';
26+
import { getPluginRoutes } from './plugin-routes';
27+
import { CoursePageSlot } from './plugin-slots/CoursePageSlot';
2628
import { TabContainer } from './tab-page';
2729

2830
import { fetchDatesTab, fetchOutlineTab, fetchProgressTab } from './course-home/data';
@@ -35,7 +37,6 @@ import CourseAccessErrorPage from './generic/CourseAccessErrorPage';
3537
import DecodePageRoute from './decode-page-route';
3638
import { DECODE_ROUTES, ROUTES } from './constants';
3739
import PreferencesUnsubscribe from './preferences-unsubscribe';
38-
import PageNotFound from './generic/PageNotFound';
3940

4041
subscribe(APP_READY, () => {
4142
const root = createRoot(document.getElementById('root'));
@@ -51,7 +52,7 @@ subscribe(APP_READY, () => {
5152
<UserMessagesProvider>
5253
<div className="app-container">
5354
<Routes>
54-
<Route path="*" element={<PageWrap><PageNotFound /></PageWrap>} />
55+
<Route path="*" element={(<CoursePageSlot />)} />
5556
<Route path={ROUTES.UNSUBSCRIBE} element={<PageWrap><GoalUnsubscribe /></PageWrap>} />
5657
<Route path={ROUTES.REDIRECT} element={<PageWrap><CoursewareRedirectLandingPage /></PageWrap>} />
5758
<Route

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 } 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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Course Page
2+
3+
### Slot ID: `org.openedx.frontend.learning.page.v1`
4+
5+
## Description
6+
7+
This slot is used to add new pages to the learning MFE.
8+
9+
## Example
10+
11+
### New static page
12+
13+
The following `env.config.jsx` will create a new URL at `/coursepage/:courseId/test`.
14+
15+
The plugin should contain a Routes component with a Route component matching the path you want to
16+
add. Note that creating sub-routes under and existing route might not work as expected.
17+
18+
```js
19+
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
20+
import { Route, Routes } from 'react-router-dom';
21+
22+
const CustomPage = () => (
23+
<Routes>
24+
<Route
25+
path="/coursepage/:courseId/test"
26+
element={<h1>Custom Page</h1>}
27+
/>
28+
</Routes>
29+
);
30+
31+
const config = {
32+
pluginSlots: {
33+
"org.openedx.frontend.learning.page.v1": {
34+
plugins: [
35+
{
36+
op: PLUGIN_OPERATIONS.Insert,
37+
widget: {
38+
id: 'custom_tab',
39+
type: DIRECT_PLUGIN,
40+
RenderWidget: CustomPage,
41+
},
42+
},
43+
],
44+
},
45+
},
46+
}
47+
48+
export default config;
49+
```
50+
51+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { PageWrap } from '@edx/frontend-platform/react';
2+
import { PluginSlot } from '@openedx/frontend-plugin-framework';
3+
import PageNotFound from '@src/generic/PageNotFound';
4+
import { Route, Routes } from 'react-router-dom';
5+
6+
export const CoursePageSlot = () => (
7+
<PluginSlot id="org.openedx.frontend.learning.page.v1">
8+
<Routes>
9+
<Route path="*" element={<PageWrap><PageNotFound /></PageWrap>} />
10+
</Routes>
11+
</PluginSlot>
12+
);
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+
```
13.2 KB
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)