refactor: plugin slot implementation (#465)
This commit is contained in:
@@ -9,9 +9,10 @@ import CourseCard from 'containers/CourseCard';
|
||||
|
||||
import { useIsCollapsed } from './hooks';
|
||||
|
||||
export const CourseList = ({
|
||||
filterOptions, setPageNumber, numPages, showFilters, visibleList,
|
||||
}) => {
|
||||
export const CourseList = ({ courseListData }) => {
|
||||
const {
|
||||
filterOptions, setPageNumber, numPages, showFilters, visibleList,
|
||||
} = courseListData;
|
||||
const isCollapsed = useIsCollapsed();
|
||||
return (
|
||||
<>
|
||||
@@ -38,14 +39,16 @@ export const CourseList = ({
|
||||
);
|
||||
};
|
||||
|
||||
CourseList.propTypes = {
|
||||
export const courseListDataShape = PropTypes.shape({
|
||||
showFilters: PropTypes.bool.isRequired,
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
visibleList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
filterOptions: PropTypes.object.isRequired,
|
||||
visibleList: PropTypes.arrayOf(PropTypes.shape()).isRequired,
|
||||
filterOptions: PropTypes.shape().isRequired,
|
||||
numPages: PropTypes.number.isRequired,
|
||||
setPageNumber: PropTypes.func.isRequired,
|
||||
});
|
||||
|
||||
CourseList.propTypes = {
|
||||
courseListData: courseListDataShape,
|
||||
};
|
||||
|
||||
export default CourseList;
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('CourseList', () => {
|
||||
useIsCollapsed.mockReturnValue(false);
|
||||
|
||||
const createWrapper = (courseListData = defaultCourseListData) => (
|
||||
shallow(<CourseList {...courseListData} />)
|
||||
shallow(<CourseList courseListData={courseListData} />)
|
||||
);
|
||||
|
||||
describe('no courses or filters', () => {
|
||||
|
||||
@@ -18,11 +18,7 @@ exports[`CoursesPanel no courses snapshot 1`] = `
|
||||
<CourseFilterControls />
|
||||
</div>
|
||||
</div>
|
||||
<PluginSlot
|
||||
id="no_courses_view"
|
||||
>
|
||||
<NoCoursesView />
|
||||
</PluginSlot>
|
||||
<NoCoursesViewSlot />
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -44,16 +40,16 @@ exports[`CoursesPanel with courses snapshot 1`] = `
|
||||
<CourseFilterControls />
|
||||
</div>
|
||||
</div>
|
||||
<PluginSlot
|
||||
id="course_list"
|
||||
>
|
||||
<CourseList
|
||||
filterOptions={{}}
|
||||
numPages={1}
|
||||
setPageNumber={[MockFunction setPageNumber]}
|
||||
showFilters={false}
|
||||
visibleList={[]}
|
||||
/>
|
||||
</PluginSlot>
|
||||
<CourseListSlot
|
||||
courseListData={
|
||||
{
|
||||
"filterOptions": {},
|
||||
"numPages": 1,
|
||||
"setPageNumber": [MockFunction setPageNumber],
|
||||
"showFilters": false,
|
||||
"visibleList": [],
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import {
|
||||
CourseFilterControls,
|
||||
} from 'containers/CourseFilterControls';
|
||||
import NoCoursesView from './NoCoursesView';
|
||||
|
||||
import CourseList from './CourseList';
|
||||
import CourseListSlot from 'plugin-slots/CourseListSlot';
|
||||
import NoCoursesViewSlot from 'plugin-slots/NoCoursesViewSlot';
|
||||
|
||||
import { useCourseListData } from './hooks';
|
||||
|
||||
@@ -34,19 +32,7 @@ export const CoursesPanel = () => {
|
||||
<CourseFilterControls {...courseListData.filterOptions} />
|
||||
</div>
|
||||
</div>
|
||||
{hasCourses ? (
|
||||
<PluginSlot
|
||||
id="course_list"
|
||||
>
|
||||
<CourseList {...courseListData} />
|
||||
</PluginSlot>
|
||||
) : (
|
||||
<PluginSlot
|
||||
id="no_courses_view"
|
||||
>
|
||||
<NoCoursesView />
|
||||
</PluginSlot>
|
||||
)}
|
||||
{hasCourses ? <CourseListSlot courseListData={courseListData} /> : <NoCoursesViewSlot />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { Container, Col, Row } from '@openedx/paragon';
|
||||
|
||||
import WidgetSidebar from '../WidgetContainers/WidgetSidebar';
|
||||
import WidgetSidebarSlot from 'plugin-slots/WidgetSidebarSlot';
|
||||
|
||||
import hooks from './hooks';
|
||||
|
||||
@@ -42,7 +42,7 @@ export const DashboardLayout = ({ children }) => {
|
||||
</Col>
|
||||
<Col {...columnConfig.sidebar} className="sidebar-column">
|
||||
{!isCollapsed && (<h2 className="course-list-title"> </h2>)}
|
||||
<WidgetSidebar />
|
||||
<WidgetSidebarSlot />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
||||
@@ -36,9 +36,9 @@ describe('DashboardLayout', () => {
|
||||
const columns = el.instance.findByType(Row)[0].findByType(Col);
|
||||
expect(columns[0].children).not.toHaveLength(0);
|
||||
});
|
||||
it('displays WidgetSidebar in second column', () => {
|
||||
it('displays WidgetSidebarSlot in second column', () => {
|
||||
const columns = el.instance.findByType(Row)[0].findByType(Col);
|
||||
expect(columns[1].findByType('WidgetSidebar')).toHaveLength(1);
|
||||
expect(columns[1].findByType('WidgetSidebarSlot')).toHaveLength(1);
|
||||
});
|
||||
};
|
||||
const testSidebarLayout = () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ exports[`DashboardLayout collapsed sidebar not showing snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<WidgetSidebar />
|
||||
<WidgetSidebarSlot />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
@@ -82,7 +82,7 @@ exports[`DashboardLayout collapsed sidebar showing snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<WidgetSidebar />
|
||||
<WidgetSidebarSlot />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
@@ -131,7 +131,7 @@ exports[`DashboardLayout not collapsed sidebar not showing snapshot 1`] = `
|
||||
>
|
||||
|
||||
</h2>
|
||||
<WidgetSidebar />
|
||||
<WidgetSidebarSlot />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
@@ -180,7 +180,7 @@ exports[`DashboardLayout not collapsed sidebar showing snapshot 1`] = `
|
||||
>
|
||||
|
||||
</h2>
|
||||
<WidgetSidebar />
|
||||
<WidgetSidebarSlot />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`WidgetSidebar snapshots 1`] = `
|
||||
<div
|
||||
className="widget-sidebar px-2"
|
||||
>
|
||||
<div
|
||||
className="d-flex"
|
||||
>
|
||||
<PluginSlot
|
||||
id="widget_sidebar_plugin_slot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
// eslint-disable-next-line arrow-body-style
|
||||
export const WidgetSidebar = () => {
|
||||
const hasCourses = reduxHooks.useHasCourses();
|
||||
|
||||
const widgetSidebarClassNames = classNames('widget-sidebar', { 'px-2': !hasCourses });
|
||||
const innerWrapperClassNames = classNames('d-flex', { 'flex-column': hasCourses });
|
||||
|
||||
return (
|
||||
<div className={widgetSidebarClassNames}>
|
||||
<div className={innerWrapperClassNames}>
|
||||
<PluginSlot id="widget_sidebar_plugin_slot" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WidgetSidebar;
|
||||
60
src/plugin-slots/CourseListSlot/README.md
Normal file
60
src/plugin-slots/CourseListSlot/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Course List Slot
|
||||
|
||||
### Slot ID: `course_list_slot`
|
||||
|
||||
## Plugin Props
|
||||
|
||||
* courseListData
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used for replacing or adding content around the `CourseList` component. The `CourseListSlot` is only rendered if the learner has enrolled in at least one course.
|
||||
|
||||
## Example
|
||||
|
||||
The space will show the `CourseList` component by default. This can be disabled in the configuration with the `keepDefault` boolean.
|
||||
|
||||

|
||||
|
||||
Setting the MFE's `env.config.jsx` to the following will replace the default experience with a list of course titles.
|
||||
|
||||

|
||||
|
||||
```js
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
course_list_slot: {
|
||||
// Hide the default CourseList component
|
||||
keepDefault: false,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_course_list',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 60,
|
||||
RenderWidget: ({ courseListData }) => {
|
||||
// Extract the "visibleList"
|
||||
const courses = courseListData.visibleList;
|
||||
// Render a list of course names
|
||||
return (
|
||||
<div>
|
||||
{courses.map(courseData => (
|
||||
<p>
|
||||
{courseData.course.courseName}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
BIN
src/plugin-slots/CourseListSlot/images/course_list_slot.png
Normal file
BIN
src/plugin-slots/CourseListSlot/images/course_list_slot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
16
src/plugin-slots/CourseListSlot/index.jsx
Normal file
16
src/plugin-slots/CourseListSlot/index.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { CourseList, courseListDataShape } from 'containers/CoursesPanel/CourseList';
|
||||
|
||||
export const CourseListSlot = ({ courseListData }) => (
|
||||
<PluginSlot id="course_list_slot" pluginProps={{ courseListData }}>
|
||||
<CourseList courseListData={courseListData} />
|
||||
</PluginSlot>
|
||||
);
|
||||
|
||||
CourseListSlot.propTypes = {
|
||||
courseListData: courseListDataShape,
|
||||
};
|
||||
|
||||
export default CourseListSlot;
|
||||
47
src/plugin-slots/NoCoursesViewSlot/README.md
Normal file
47
src/plugin-slots/NoCoursesViewSlot/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# No Courses View Slot
|
||||
|
||||
### Slot ID: `no_courses_view_slot`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used for replacing or adding content around the `NoCoursesView` component. The `NoCoursesViewSlot` only renders if the learner has not yet enrolled in any courses.
|
||||
|
||||
## Example
|
||||
|
||||
The space will show the `NoCoursesView` by default. This can be disabled in the configuration with the `keepDefault` boolean.
|
||||
|
||||

|
||||
|
||||
Setting the MFE's `env.config.jsx` to the following will replace the default experience with a custom call-to-action component.
|
||||
|
||||

|
||||
|
||||
```js
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
no_courses_view_slot: {
|
||||
// Hide the default NoCoursesView component
|
||||
keepDefault: false,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_no_courses_CTA',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 60,
|
||||
RenderWidget: () => (
|
||||
<h3>
|
||||
Check out our catalog of courses and start learning today!
|
||||
</h3>
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
12
src/plugin-slots/NoCoursesViewSlot/index.jsx
Normal file
12
src/plugin-slots/NoCoursesViewSlot/index.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import NoCoursesView from 'containers/CoursesPanel/NoCoursesView';
|
||||
|
||||
export const NoCoursesViewSlot = () => (
|
||||
<PluginSlot id="no_courses_view_slot">
|
||||
<NoCoursesView />
|
||||
</PluginSlot>
|
||||
);
|
||||
|
||||
export default NoCoursesViewSlot;
|
||||
@@ -1,3 +1,6 @@
|
||||
# `frontend-app-learner-dashboard` Plugin Slots
|
||||
|
||||
* [`footer_slot`](./FooterSlot/)
|
||||
* [`widget_sidebar_slot`](./WidgetSidebarSlot/)
|
||||
* [`course_list_slot`](./CourseListSlot/)
|
||||
* [`no_courses_view_slot`](./NoCoursesViewSlot/)
|
||||
58
src/plugin-slots/WidgetSidebarSlot/README.md
Normal file
58
src/plugin-slots/WidgetSidebarSlot/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Widget Sidebar Slot
|
||||
|
||||
### Slot ID: `widget_sidebar_slot`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used for adding content to the right-hand sidebar.
|
||||
|
||||
## Example
|
||||
|
||||
The space will show the `LookingForChallengeWidget` by default. This can be disabled in the configuration with the `keepDefault` boolean.
|
||||
|
||||

|
||||
|
||||
Setting the MFE's `env.config.jsx` to the following will replace the default experience with a custom sidebar component.
|
||||
|
||||

|
||||
|
||||
```js
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
widget_sidebar_slot: {
|
||||
// Hide the default LookingForChallenge component
|
||||
keepDefault: false,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_sidebar_panel',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 60,
|
||||
RenderWidget: () => (
|
||||
<div>
|
||||
<h3>
|
||||
Sidebar Menu
|
||||
</h3>
|
||||
<p>
|
||||
sidebar item #1
|
||||
</p>
|
||||
<p>
|
||||
sidebar item #2
|
||||
</p>
|
||||
<p>
|
||||
sidebar item #3
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`WidgetSidebar snapshots 1`] = `
|
||||
<PluginSlot
|
||||
id="widget_sidebar_slot"
|
||||
>
|
||||
<LookingForChallengeWidget />
|
||||
</PluginSlot>
|
||||
`;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
13
src/plugin-slots/WidgetSidebarSlot/index.jsx
Normal file
13
src/plugin-slots/WidgetSidebarSlot/index.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import LookingForChallengeWidget from 'widgets/LookingForChallengeWidget';
|
||||
|
||||
// eslint-disable-next-line arrow-body-style
|
||||
export const WidgetSidebarSlot = () => (
|
||||
<PluginSlot id="widget_sidebar_slot">
|
||||
<LookingForChallengeWidget />
|
||||
</PluginSlot>
|
||||
);
|
||||
|
||||
export default WidgetSidebarSlot;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import WidgetSidebar from '.';
|
||||
import WidgetSidebarSlot from '.';
|
||||
|
||||
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('WidgetSidebar', () => {
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
test('snapshots', () => {
|
||||
const wrapper = shallow(<WidgetSidebar />);
|
||||
const wrapper = shallow(<WidgetSidebarSlot />);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ jest.unmock('react-redux');
|
||||
jest.unmock('reselect');
|
||||
jest.unmock('hooks');
|
||||
|
||||
jest.mock('containers/WidgetContainers/WidgetSidebar', () => jest.fn(() => 'widget-sidebar'));
|
||||
jest.mock('plugin-slots/WidgetSidebarSlot', () => jest.fn(() => 'widget-sidebar'));
|
||||
jest.mock('components/NoticesWrapper', () => 'notices-wrapper');
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { reduxHooks } from 'hooks';
|
||||
import moreCoursesSVG from 'assets/more-courses-sidewidget.svg';
|
||||
import { baseAppUrl } from 'data/services/lms/urls';
|
||||
|
||||
import track from './track';
|
||||
import { findCoursesWidgetClicked } from './track';
|
||||
import messages from './messages';
|
||||
import './index.scss';
|
||||
|
||||
@@ -17,6 +17,8 @@ export const arrowIcon = (<Icon className="mx-1" src={ArrowForward} />);
|
||||
export const LookingForChallengeWidget = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { courseSearchUrl } = reduxHooks.usePlatformSettingsData();
|
||||
const hyperlinkDestination = baseAppUrl(courseSearchUrl) || '';
|
||||
|
||||
return (
|
||||
<Card orientation="horizontal" id="looking-for-challenge-widget">
|
||||
<Card.ImageCap
|
||||
@@ -30,8 +32,8 @@ export const LookingForChallengeWidget = () => {
|
||||
<h5>
|
||||
<Hyperlink
|
||||
variant="brand"
|
||||
destination={baseAppUrl(courseSearchUrl)}
|
||||
onClick={track.findCoursesWidgetClicked(baseAppUrl(courseSearchUrl))}
|
||||
destination={hyperlinkDestination}
|
||||
onClick={findCoursesWidgetClicked(hyperlinkDestination)}
|
||||
className="d-flex align-items-center"
|
||||
>
|
||||
{formatMessage(messages.findCoursesButton, { arrow: arrowIcon })}
|
||||
|
||||
@@ -8,3 +8,8 @@ export const linkNames = StrictDict({
|
||||
export const findCoursesWidgetClicked = (href) => track.findCourses.findCoursesClicked(href, {
|
||||
linkName: linkNames.findCoursesWidget,
|
||||
});
|
||||
|
||||
export default {
|
||||
linkNames,
|
||||
findCoursesWidgetClicked,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user