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 +![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: ()=> ( + + 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} + + ))} + +);