From fd9df357656e5492374b58a9005c3e21b6f603f8 Mon Sep 17 00:00:00 2001 From: "kshitij.sobti" Date: Fri, 21 Mar 2025 17:31:29 +0530 Subject: [PATCH 1/6] feat: Add slots to add tab links and add mechanism for plugin routes --- ...avigation.jsx => CourseTabsNavigation.tsx} | 51 +++++++----------- src/index.jsx | 2 + src/plugin-routes.test.tsx | 50 +++++++++++++++++ src/plugin-routes.tsx | 18 +++++++ src/plugin-slots/CoursePageSlot/README.md | 49 +++++++++++++++++ src/plugin-slots/CoursePageSlot/index.tsx | 3 ++ src/plugin-slots/CourseTabLinksSlot/README.md | 45 ++++++++++++++++ .../CourseTabLinksSlot/course-tabs-custom.png | Bin 0 -> 13517 bytes src/plugin-slots/CourseTabLinksSlot/index.tsx | 23 ++++++++ 9 files changed, 210 insertions(+), 31 deletions(-) rename src/course-tabs/{CourseTabsNavigation.jsx => CourseTabsNavigation.tsx} (58%) create mode 100644 src/plugin-routes.test.tsx create mode 100644 src/plugin-routes.tsx create mode 100644 src/plugin-slots/CoursePageSlot/README.md create mode 100644 src/plugin-slots/CoursePageSlot/index.tsx create mode 100644 src/plugin-slots/CourseTabLinksSlot/README.md create mode 100644 src/plugin-slots/CourseTabLinksSlot/course-tabs-custom.png create mode 100644 src/plugin-slots/CourseTabLinksSlot/index.tsx 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..88abceb411 --- /dev/null +++ b/src/plugin-routes.test.tsx @@ -0,0 +1,50 @@ +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, pluginProps }: { id: string; pluginProps: Record }) => ( +
+ id: {id}, route: {pluginProps.route} +
+ ), +})); + +describe('getPluginRoutes', () => { + it('should return a valid route element for each plugin route', () => { + const pluginRoutes = ['/route-1', '/route-2']; + (getConfig as jest.Mock).mockImplementation(() => ({ + PLUGIN_ROUTES: pluginRoutes, + })); + + const result = getPluginRoutes(); + const { container } = render(<>{result}); + + pluginRoutes.forEach((route) => { + expect(container.querySelector(`[data-route="${route}"]`)).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..8f00c1bdb5 --- /dev/null +++ b/src/plugin-routes.tsx @@ -0,0 +1,18 @@ +import { getConfig } from '@edx/frontend-platform'; +import { Route } from 'react-router-dom'; +import { CoursePageSlot } from './plugin-slots/CoursePageSlot'; +import DecodePageRoute from './decode-page-route'; + +export function getPluginRoutes() { + return getConfig()?.PLUGIN_ROUTES?.map((route: string) => ( + + + + )} + /> + )) ?? null; +} diff --git a/src/plugin-slots/CoursePageSlot/README.md b/src/plugin-slots/CoursePageSlot/README.md new file mode 100644 index 0000000000..0b15872a51 --- /dev/null +++ b/src/plugin-slots/CoursePageSlot/README.md @@ -0,0 +1,49 @@ +# Course Page + +### Slot ID: `org.openedx.frontend.learning.course_page.v1` + +### Slot ID Aliases +* `course_page` + +### Props: +* `route` + +## Description + +This slot is used to add 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. + +```js +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + PLUGIN_ROUTES: ["/course/:courseId/test"], + pluginSlots: { + "org.openedx.frontend.learning.course_page.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..0397c6611c --- /dev/null +++ b/src/plugin-slots/CoursePageSlot/index.tsx @@ -0,0 +1,3 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; + +export const CoursePageSlot = ({ route } : { route: 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 0000000000000000000000000000000000000000..4fcdf3add57c13f44bd6b2f9eecd20ea97de6ac1 GIT binary patch literal 13517 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 literal 0 HcmV?d00001 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} + + ))} + +); From 932aa1b01248c798515f19598aea81409471d407 Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Tue, 24 Jun 2025 16:36:27 +0530 Subject: [PATCH 2/6] refactor: Use dynamic slot ids for pluggable pages. --- src/plugin-routes.tsx | 9 +++++++-- src/plugin-slots/CoursePageSlot/README.md | 23 ++++++++++++----------- src/plugin-slots/CoursePageSlot/index.tsx | 4 +++- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/plugin-routes.tsx b/src/plugin-routes.tsx index 8f00c1bdb5..9895fb89e0 100644 --- a/src/plugin-routes.tsx +++ b/src/plugin-routes.tsx @@ -3,14 +3,19 @@ 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?.map((route: string) => ( + return (getConfig()?.PLUGIN_ROUTES as PluginRoute[])?.map(({route, id}) => ( - + )} /> diff --git a/src/plugin-slots/CoursePageSlot/README.md b/src/plugin-slots/CoursePageSlot/README.md index 0b15872a51..cb1d8cbff6 100644 --- a/src/plugin-slots/CoursePageSlot/README.md +++ b/src/plugin-slots/CoursePageSlot/README.md @@ -1,16 +1,10 @@ # Course Page -### Slot ID: `org.openedx.frontend.learning.course_page.v1` - -### Slot ID Aliases -* `course_page` - -### Props: -* `route` +### Slot ID: `org.openedx.frontend.learning.course_page..v1` ## Description -This slot is used to add new course page to the learning MFE. +This slot is used to add a new course page to the learning MFE. ## Example @@ -20,15 +14,22 @@ This slot is used to add new course page to the learning MFE. 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. +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: ["/course/:courseId/test"], + PLUGIN_ROUTES: [{ id: 'test', route: '/course/:courseId/test' }], pluginSlots: { - "org.openedx.frontend.learning.course_page.v1": { + "org.openedx.frontend.learning.course_page.test.v1": { plugins: [ { op: PLUGIN_OPERATIONS.Insert, diff --git a/src/plugin-slots/CoursePageSlot/index.tsx b/src/plugin-slots/CoursePageSlot/index.tsx index 0397c6611c..fec1ec0443 100644 --- a/src/plugin-slots/CoursePageSlot/index.tsx +++ b/src/plugin-slots/CoursePageSlot/index.tsx @@ -1,3 +1,5 @@ import { PluginSlot } from '@openedx/frontend-plugin-framework'; -export const CoursePageSlot = ({ route } : { route: string }) => ; +export const CoursePageSlot = ({ pageId } : { pageId: string }) => ( + +); From f97d2b38b3ee709da605f86ec89df01353402a05 Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Tue, 24 Jun 2025 18:28:06 +0530 Subject: [PATCH 3/6] fixup! refactor: Use dynamic slot ids for pluggable pages. --- src/plugin-routes.tsx | 8 ++++---- src/plugin-slots/CoursePageSlot/index.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/plugin-routes.tsx b/src/plugin-routes.tsx index 9895fb89e0..2c95750f26 100644 --- a/src/plugin-routes.tsx +++ b/src/plugin-routes.tsx @@ -4,12 +4,12 @@ import { CoursePageSlot } from './plugin-slots/CoursePageSlot'; import DecodePageRoute from './decode-page-route'; type PluginRoute = { - id: string, - route: string, -} + id: string, + route: string, +}; export function getPluginRoutes() { - return (getConfig()?.PLUGIN_ROUTES as PluginRoute[])?.map(({route, id}) => ( + return (getConfig()?.PLUGIN_ROUTES as PluginRoute[])?.map(({ route, id }) => ( ( - + ); From cad941800d96d9675ecf74b16e7d59e72d13b5de Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Tue, 24 Jun 2025 19:24:14 +0530 Subject: [PATCH 4/6] fixup! fixup! refactor: Use dynamic slot ids for pluggable pages. --- src/plugin-routes.test.tsx | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/plugin-routes.test.tsx b/src/plugin-routes.test.tsx index 88abceb411..de6e08e187 100644 --- a/src/plugin-routes.test.tsx +++ b/src/plugin-routes.test.tsx @@ -1,5 +1,5 @@ import { getConfig } from '@edx/frontend-platform'; -import { render } from '@testing-library/react'; +import { render,screen } from '@testing-library/react'; import React from 'react'; import { getPluginRoutes } from './plugin-routes'; @@ -18,31 +18,33 @@ jest.mock('./decode-page-route', () => ({ })); jest.mock('@openedx/frontend-plugin-framework', () => ({ - PluginSlot: ({ id, pluginProps }: { id: string; pluginProps: Record }) => ( -
- id: {id}, route: {pluginProps.route} + PluginSlot: ({ id }: { id: string }) => ( +
+ id: {id}
), })); describe('getPluginRoutes', () => { it('should return a valid route element for each plugin route', () => { - const pluginRoutes = ['/route-1', '/route-2']; + const pluginRoutes = [ + {id: 'route-1', route: '/route-1'}, + {id: 'route-2', route: '/route-2'}, + ]; (getConfig as jest.Mock).mockImplementation(() => ({ PLUGIN_ROUTES: pluginRoutes, })); - const result = getPluginRoutes(); - const { container } = render(<>{result}); + const { container } = render(getPluginRoutes()); - pluginRoutes.forEach((route) => { - expect(container.querySelector(`[data-route="${route}"]`)).toBeInTheDocument(); + 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(() => ({})); + (getConfig as jest.Mock).mockImplementation(() => ([])); const result = getPluginRoutes(); expect(result).toBeNull(); From 884d54544122156b948aa2dc2bebb5783a259be7 Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Tue, 24 Jun 2025 19:33:30 +0530 Subject: [PATCH 5/6] fixup! fixup! fixup! refactor: Use dynamic slot ids for pluggable pages. --- src/plugin-routes.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugin-routes.test.tsx b/src/plugin-routes.test.tsx index de6e08e187..dbd1fdc827 100644 --- a/src/plugin-routes.test.tsx +++ b/src/plugin-routes.test.tsx @@ -1,5 +1,5 @@ import { getConfig } from '@edx/frontend-platform'; -import { render,screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import React from 'react'; import { getPluginRoutes } from './plugin-routes'; From d897611c2e4f3b9f18b4d342763375fc9e21fca4 Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Wed, 25 Jun 2025 14:31:06 +0530 Subject: [PATCH 6/6] fixup! fixup! fixup! fixup! refactor: Use dynamic slot ids for pluggable pages. --- src/plugin-routes.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugin-routes.test.tsx b/src/plugin-routes.test.tsx index dbd1fdc827..102fa3da45 100644 --- a/src/plugin-routes.test.tsx +++ b/src/plugin-routes.test.tsx @@ -28,8 +28,8 @@ jest.mock('@openedx/frontend-plugin-framework', () => ({ 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'}, + { id: 'route-1', route: '/route-1' }, + { id: 'route-2', route: '/route-2' }, ]; (getConfig as jest.Mock).mockImplementation(() => ({ PLUGIN_ROUTES: pluginRoutes, @@ -37,7 +37,7 @@ describe('getPluginRoutes', () => { const { container } = render(getPluginRoutes()); - pluginRoutes.forEach(({id}) => { + 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);