Skip to content

feat: Add slots to add tab links and add mechanism for plugin routes #1645

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -23,15 +35,7 @@ const CourseTabsNavigation = ({
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={url}
>
{title}
</a>
))}
<CourseTabLinksSlot tabs={tabs} activeTabSlug={activeTabSlug} />
</Tabs>
</div>
<div className="search-toggle">
Expand All @@ -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;
2 changes: 2 additions & 0 deletions src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -143,6 +144,7 @@ subscribe(APP_READY, () => {
)}
/>
))}
{getPluginRoutes()}
</Routes>
</div>
</UserMessagesProvider>
Expand Down
52 changes: 52 additions & 0 deletions src/plugin-routes.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="plugin-slot" data-plugin-id={id}>
id: {id}
</div>
),
}));

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();
});
});
23 changes: 23 additions & 0 deletions src/plugin-routes.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<Route
key={route}
path={route}
element={(
<DecodePageRoute>
<CoursePageSlot pageId={id} />
</DecodePageRoute>
)}
/>
)) ?? null;
}
50 changes: 50 additions & 0 deletions src/plugin-slots/CoursePageSlot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Course Page

### Slot ID: `org.openedx.frontend.learning.course_page.<pageId>.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: ()=> (<h1>Custom Page</h1>),
},
},
],
},
},
}

export default config;
```


5 changes: 5 additions & 0 deletions src/plugin-slots/CoursePageSlot/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PluginSlot } from '@openedx/frontend-plugin-framework';

export const CoursePageSlot = ({ pageId } : { pageId: string }) => (
<PluginSlot id={`org.openedx.frontend.learning.course_page.${pageId}.v1`} />
);
45 changes: 45 additions & 0 deletions src/plugin-slots/CourseTabLinksSlot/README.md
Original file line number Diff line number Diff line change
@@ -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
![Added "Custom Tab" to course tabs](./course-tabs-custom.png)

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: ()=> (
<a
className={classNames('nav-item flex-shrink-0 nav-link')}
href="#"
>
Custom Tab
</a>
),
},
},
],
},
},
}

export default config;
```
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions src/plugin-slots/CourseTabLinksSlot/index.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<PluginSlot id="org.openedx.frontend.learning.course_tab_links.v1" pluginProps={{ activeTabSlug }}>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={url}
>
{title}
</a>
))}
</PluginSlot>
);