refactor: plugin slot implementation (#465)

This commit is contained in:
Maxwell Frank
2024-10-15 15:04:52 -04:00
committed by GitHub
parent 134c741cf8
commit 0408a54372
27 changed files with 266 additions and 94 deletions

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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>
`;

View File

@@ -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>
);
};

View File

@@ -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">&nbsp;</h2>)}
<WidgetSidebar />
<WidgetSidebarSlot />
</Col>
</Row>
</Container>

View File

@@ -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 = () => {

View File

@@ -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>

View File

@@ -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>
`;

View File

@@ -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;

View 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.
![Screenshot of the CourseListSlot](./images/course_list_slot.png)
Setting the MFE's `env.config.jsx` to the following will replace the default experience with a list of course titles.
![Screenshot of a custom course list](./images/readme_custom_course_list.png)
```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;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View 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;

View 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.
![Screenshot of the no courses view](./images/no_courses_view_slot.png)
Setting the MFE's `env.config.jsx` to the following will replace the default experience with a custom call-to-action component.
![Screenshot of a custom no courses view](./images/readme_custom_no_courses_view.png)
```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

View 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;

View File

@@ -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/)

View 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.
![Screenshot of the widget sidebar](./images/widget_sidebar_slot.png)
Setting the MFE's `env.config.jsx` to the following will replace the default experience with a custom sidebar component.
![Screenshot of a custom call-to-action in the sidebar](./images/readme_custom_sidebar.png)
```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;
```

View File

@@ -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

View 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;

View File

@@ -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();
});
});

View File

@@ -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', () => ({

View File

@@ -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 })}

View File

@@ -8,3 +8,8 @@ export const linkNames = StrictDict({
export const findCoursesWidgetClicked = (href) => track.findCourses.findCoursesClicked(href, {
linkName: linkNames.findCoursesWidget,
});
export default {
linkNames,
findCoursesWidgetClicked,
};