Refactor course list and empty state (#308)

Co-authored-by: Maxwell Frank <mfrank@2u.com>
This commit is contained in:
Maxwell Frank
2024-04-03 13:41:20 -04:00
committed by GitHub
parent e045932e5f
commit b8e08d8a8f
26 changed files with 305 additions and 308 deletions

2
package-lock.json generated
View File

@@ -20,7 +20,7 @@
"@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.1.15",
"@openedx/frontend-plugin-framework": "1.0.2",
"@openedx/frontend-plugin-framework": "^1.0.2",
"@openedx/paragon": "^21.11.3",
"@optimizely/react-sdk": "^2.9.2",
"@redux-beacon/segment": "^1.1.0",

View File

@@ -33,11 +33,11 @@
"@edx/frontend-platform": "7.1.0",
"@edx/openedx-atlas": "^0.6.0",
"@edx/react-unit-test-utils": "2.0.0",
"@openedx/frontend-plugin-framework": "1.0.2",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.1.15",
"@openedx/frontend-plugin-framework": "^1.0.2",
"@openedx/paragon": "^21.11.3",
"@optimizely/react-sdk": "^2.9.2",
"@redux-beacon/segment": "^1.1.0",

View File

@@ -1,185 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseList collapsed with multiple courses and pages snapshot 1`] = `
<div
className="course-list-container"
>
<div
className="course-list-heading-container"
>
<h2
className="course-list-title"
>
My Courses
</h2>
<div
className="course-filter-controls-container"
>
<CourseFilterControls />
</div>
</div>
<Fragment>
<div
id="course-list-active-filters-container"
>
<ActiveCourseFilters />
</div>
<div
className="d-flex flex-column flex-grow-1"
>
<CourseCard
cardId="foo"
key="foo"
/>
<CourseCard
cardId="bar"
key="bar"
/>
<CourseCard
cardId="baz"
key="baz"
/>
<Pagination
className="mx-auto mb-2"
onPageSelect={[MockFunction setPageNumber]}
pageCount={3}
paginationLabel="Course List"
variant="reduced"
/>
</div>
</Fragment>
</div>
`;
exports[`CourseList no courses snapshot 1`] = `
<div
className="course-list-container"
>
<div
className="course-list-heading-container"
>
<h2
className="course-list-title"
>
My Courses
</h2>
<div
className="course-filter-controls-container"
>
<CourseFilterControls />
</div>
</div>
<Fragment>
<div
className="d-flex flex-column flex-grow-1"
/>
</Fragment>
</div>
`;
exports[`CourseList no filters snapshot 1`] = `
<div
className="course-list-container"
>
<div
className="course-list-heading-container"
>
<h2
className="course-list-title"
>
My Courses
</h2>
<div
className="course-filter-controls-container"
>
<CourseFilterControls />
</div>
</div>
<Fragment>
<div
className="d-flex flex-column flex-grow-1"
/>
</Fragment>
</div>
`;
exports[`CourseList with filters snapshot 1`] = `
<div
className="course-list-container"
>
<div
className="course-list-heading-container"
>
<h2
className="course-list-title"
>
My Courses
</h2>
<div
className="course-filter-controls-container"
>
<CourseFilterControls
abitary="filter"
/>
</div>
</div>
<Fragment>
<div
id="course-list-active-filters-container"
>
<ActiveCourseFilters
abitary="filter"
/>
</div>
<div
className="d-flex flex-column flex-grow-1"
/>
</Fragment>
</div>
`;
exports[`CourseList with multiple courses and pages snapshot 1`] = `
<div
className="course-list-container"
>
<div
className="course-list-heading-container"
>
<h2
className="course-list-title"
>
My Courses
</h2>
<div
className="course-filter-controls-container"
>
<CourseFilterControls />
</div>
</div>
<Fragment>
<div
className="d-flex flex-column flex-grow-1"
>
<CourseCard
cardId="foo"
key="foo"
/>
<CourseCard
cardId="bar"
key="bar"
/>
<CourseCard
cardId="baz"
key="baz"
/>
<Pagination
className="mx-auto mb-2"
onPageSelect={[MockFunction setPageNumber]}
pageCount={3}
paginationLabel="Course List"
variant="secondary"
/>
</div>
</Fragment>
</div>
`;

View File

@@ -1,77 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Pagination } from '@openedx/paragon';
import { reduxHooks } from 'hooks';
import {
ActiveCourseFilters,
CourseFilterControls,
} from 'containers/CourseFilterControls';
import CourseCard from 'containers/CourseCard';
import NoCoursesView from './NoCoursesView';
import { useCourseListData, useIsCollapsed } from './hooks';
import messages from './messages';
import './index.scss';
/**
* Renders the list of CourseCards, as well as the controls (CourseFilterControls) for modifying the list.
* Also houses the NoCoursesView to display if the user hasn't enrolled in any courses.
* @returns List of courses as CourseCards
*/
export const CourseList = () => {
const { formatMessage } = useIntl();
const hasCourses = reduxHooks.useHasCourses();
const {
filterOptions,
setPageNumber,
numPages,
showFilters,
visibleList,
} = useCourseListData();
const isCollapsed = useIsCollapsed();
return (
<div className="course-list-container">
<div className="course-list-heading-container">
<h2 className="course-list-title">{formatMessage(messages.myCourses)}</h2>
<div className="course-filter-controls-container">
<CourseFilterControls {...filterOptions} />
</div>
</div>
{hasCourses
? (
<>
{showFilters && (
<div id="course-list-active-filters-container">
<ActiveCourseFilters {...filterOptions} />
</div>
)}
<div className="d-flex flex-column flex-grow-1">
{visibleList.map(({ cardId }) => (
<CourseCard key={cardId} cardId={cardId} />
))}
{numPages > 1 && (
<Pagination
variant={isCollapsed ? 'reduced' : 'secondary'}
paginationLabel="Course List"
className="mx-auto mb-2"
pageCount={numPages}
onPageSelect={setPageNumber}
/>
)}
</div>
</>
) : (
<NoCoursesView />
)}
</div>
);
};
CourseList.propTypes = {};
export default CourseList;

View File

@@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseList collapsed with multiple courses and pages snapshot 1`] = `
<Fragment>
<div
id="course-list-active-filters-container"
>
<ActiveCourseFilters />
</div>
<div
className="d-flex flex-column flex-grow-1"
>
<CourseCard
cardId="foo"
key="foo"
/>
<CourseCard
cardId="bar"
key="bar"
/>
<CourseCard
cardId="baz"
key="baz"
/>
<Pagination
className="mx-auto mb-2"
pageCount={3}
paginationLabel="Course List"
variant="reduced"
/>
</div>
</Fragment>
`;
exports[`CourseList no courses or filters snapshot 1`] = `
<Fragment>
<div
className="d-flex flex-column flex-grow-1"
/>
</Fragment>
`;
exports[`CourseList with filters snapshot 1`] = `undefined`;
exports[`CourseList with multiple courses and pages snapshot 1`] = `
<Fragment>
<div
className="d-flex flex-column flex-grow-1"
>
<CourseCard
cardId="foo"
key="foo"
/>
<CourseCard
cardId="bar"
key="bar"
/>
<CourseCard
cardId="baz"
key="baz"
/>
<Pagination
className="mx-auto mb-2"
pageCount={3}
paginationLabel="Course List"
variant="secondary"
/>
</div>
</Fragment>
`;

View File

@@ -0,0 +1,8 @@
import { useWindowSize, breakpoints } from '@openedx/paragon';
export const useIsCollapsed = () => {
const { width } = useWindowSize();
return width < breakpoints.medium.maxWidth;
};
export default useIsCollapsed;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Pagination } from '@openedx/paragon';
import {
ActiveCourseFilters,
} from 'containers/CourseFilterControls';
import CourseCard from 'containers/CourseCard';
import { useIsCollapsed } from './hooks';
export const CourseList = ({
filterOptions, setPageNumber, numPages, showFilters, visibleList,
}) => {
const isCollapsed = useIsCollapsed();
return (
<>
{showFilters && (
<div id="course-list-active-filters-container">
<ActiveCourseFilters {...filterOptions} />
</div>
)}
<div className="d-flex flex-column flex-grow-1">
{visibleList.map(({ cardId }) => (
<CourseCard key={cardId} cardId={cardId} />
))}
{numPages > 1 && (
<Pagination
variant={isCollapsed ? 'reduced' : 'secondary'}
paginationLabel="Course List"
className="mx-auto mb-2"
pageCount={numPages}
onPageSelect={setPageNumber}
/>
)}
</div>
</>
);
};
CourseList.propTypes = {
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,
numPages: PropTypes.number.isRequired,
setPageNumber: PropTypes.func.isRequired,
};
export default CourseList;

View File

@@ -1,26 +1,17 @@
import { shallow } from '@edx/react-unit-test-utils';
import { reduxHooks } from 'hooks';
import { useCourseListData, useIsCollapsed } from './hooks';
import { useIsCollapsed } from './hooks';
import CourseList from '.';
jest.mock('hooks', () => ({
reduxHooks: { useHasCourses: jest.fn() },
}));
jest.mock('./hooks', () => ({
useCourseListData: jest.fn(),
useIsCollapsed: jest.fn(),
}));
jest.mock('containers/CourseCard', () => 'CourseCard');
jest.mock('containers/CourseFilterControls', () => ({
ActiveCourseFilters: 'ActiveCourseFilters',
CourseFilterControls: 'CourseFilterControls',
}));
reduxHooks.useHasCourses.mockReturnValue(true);
describe('CourseList', () => {
const defaultCourseListData = {
filterOptions: {},
@@ -30,22 +21,12 @@ describe('CourseList', () => {
visibleList: [],
};
useIsCollapsed.mockReturnValue(false);
const createWrapper = (courseListData) => {
useCourseListData.mockReturnValueOnce({
...defaultCourseListData,
...courseListData,
});
return shallow(<CourseList />);
};
describe('no courses', () => {
test('snapshot', () => {
reduxHooks.useHasCourses.mockReturnValue(true);
const wrapper = createWrapper();
expect(wrapper.snapshot).toMatchSnapshot();
});
});
describe('no filters', () => {
const createWrapper = (courseListData = defaultCourseListData) => (
shallow(<CourseList {...courseListData} />)
);
describe('no courses or filters', () => {
test('snapshot', () => {
const wrapper = createWrapper();
expect(wrapper.snapshot).toMatchSnapshot();

View File

@@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoursesPanel no courses snapshot 1`] = `
<div
className="course-list-container"
>
<div
className="course-list-heading-container"
>
<h2
className="course-list-title"
>
My Courses
</h2>
<div
className="course-filter-controls-container"
>
<CourseFilterControls />
</div>
</div>
<NoCoursesView />
</div>
`;
exports[`CoursesPanel with courses snapshot 1`] = `
<div
className="course-list-container"
>
<div
className="course-list-heading-container"
>
<h2
className="course-list-title"
>
My Courses
</h2>
<div
className="course-filter-controls-container"
>
<CourseFilterControls />
</div>
</div>
<CourseList
filterOptions={Object {}}
numPages={1}
setPageNumber={[MockFunction setPageNumber]}
showFilters={false}
visibleList={Array []}
/>
</div>
`;

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { useWindowSize, breakpoints } from '@openedx/paragon';
import queryString from 'query-string';
import { ListPageSize, SortKeys } from 'data/constants/app';
@@ -9,21 +8,16 @@ import { StrictDict } from 'utils';
import * as module from './hooks';
export const useIsCollapsed = () => {
const { width } = useWindowSize();
return width < breakpoints.medium.maxWidth;
};
export const state = StrictDict({
sortBy: (val) => React.useState(val), // eslint-disable-line
});
/**
* Filters are fetched from the store and used to generate a list of "visible" courses.
* Other values returned and used for the layout of the CourseList component are:
* Other values returned and used for the layout of the CoursesPanel component are:
* the current page number, the sorting method, and whether or not to enable filters and pagination.
*
* @returns data for the CourseList component
* @returns data for the CoursesPanel component
*/
export const useCourseListData = () => {
const filters = reduxHooks.useFilters();

View File

@@ -0,0 +1,48 @@
import React from 'react';
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 { useCourseListData } from './hooks';
import messages from './messages';
import './index.scss';
/**
* Renders the list of CourseCards, as well as the controls (CourseFilterControls) for modifying the list.
* Also houses the NoCoursesView to display if the user hasn't enrolled in any courses.
* @returns List of courses as CourseCards or empty state
*/
export const CoursesPanel = () => {
const { formatMessage } = useIntl();
const hasCourses = reduxHooks.useHasCourses();
const courseListData = useCourseListData();
return (
<div className="course-list-container">
<div className="course-list-heading-container">
<h2 className="course-list-title">{formatMessage(messages.myCourses)}</h2>
<div className="course-filter-controls-container">
<CourseFilterControls {...courseListData.filterOptions} />
</div>
</div>
{hasCourses
? (
<CourseList {...courseListData} />
) : (
<NoCoursesView />
)}
</div>
);
};
CoursesPanel.propTypes = {};
export default CoursesPanel;

View File

@@ -0,0 +1,55 @@
import { shallow } from '@edx/react-unit-test-utils';
import { reduxHooks } from 'hooks';
import { useCourseListData } from './hooks';
import CoursesPanel from '.';
jest.mock('hooks', () => ({
reduxHooks: { useHasCourses: jest.fn() },
}));
jest.mock('./hooks', () => ({
useCourseListData: jest.fn(),
}));
jest.mock('containers/CourseCard', () => 'CourseCard');
jest.mock('containers/CourseFilterControls', () => ({
ActiveCourseFilters: 'ActiveCourseFilters',
CourseFilterControls: 'CourseFilterControls',
}));
jest.mock('./CourseList', () => 'CourseList');
reduxHooks.useHasCourses.mockReturnValue(true);
describe('CoursesPanel', () => {
const defaultCourseListData = {
filterOptions: {},
numPages: 1,
setPageNumber: jest.fn().mockName('setPageNumber'),
showFilters: false,
visibleList: [],
};
const createWrapper = (courseListData) => {
useCourseListData.mockReturnValueOnce({
...defaultCourseListData,
...courseListData,
});
return shallow(<CoursesPanel />);
};
describe('no courses', () => {
test('snapshot', () => {
reduxHooks.useHasCourses.mockReturnValue(false);
const wrapper = createWrapper();
expect(wrapper.snapshot).toMatchSnapshot();
});
});
describe('with courses', () => {
test('snapshot', () => {
reduxHooks.useHasCourses.mockReturnValue(true);
const wrapper = createWrapper();
expect(wrapper.snapshot).toMatchSnapshot();
});
});
});

View File

@@ -20,7 +20,7 @@ exports[`Dashboard snapshots courses loaded, show select session modal, no avail
<DashboardLayout
sidebar="LoadedWidgetSidebar"
>
<CourseList />
<CoursesPanel />
</DashboardLayout>
</div>
</div>
@@ -65,7 +65,7 @@ exports[`Dashboard snapshots there are no courses, there ARE available dashboard
<DashboardLayout
sidebar="NoCoursesWidgetSidebar"
>
<CourseList />
<CoursesPanel />
</DashboardLayout>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import { reduxHooks } from 'hooks';
import { RequestKeys } from 'data/constants/requests';
import EnterpriseDashboardModal from 'containers/EnterpriseDashboardModal';
import SelectSessionModal from 'containers/SelectSessionModal';
import CourseList from 'containers/CourseList';
import CoursesPanel from 'containers/CoursesPanel';
import LoadedSidebar from 'containers/WidgetContainers/LoadedSidebar';
import NoCoursesSidebar from 'containers/WidgetContainers/NoCoursesSidebar';
@@ -36,7 +36,7 @@ export const Dashboard = () => {
? (<LoadingView />)
: (
<DashboardLayout sidebar={hasCourses ? LoadedSidebar : NoCoursesSidebar}>
<CourseList />
<CoursesPanel />
</DashboardLayout>
)}
</div>

View File

@@ -4,7 +4,7 @@ import { reduxHooks } from 'hooks';
import EnterpriseDashboardModal from 'containers/EnterpriseDashboardModal';
import SelectSessionModal from 'containers/SelectSessionModal';
import CourseList from 'containers/CourseList';
import CoursesPanel from 'containers/CoursesPanel';
import LoadedWidgetSidebar from 'containers/WidgetContainers/LoadedSidebar';
import NoCoursesWidgetSidebar from 'containers/WidgetContainers/NoCoursesSidebar';
@@ -24,7 +24,7 @@ jest.mock('hooks', () => ({
}));
jest.mock('containers/EnterpriseDashboardModal', () => 'EnterpriseDashboardModal');
jest.mock('containers/CourseList', () => 'CourseList');
jest.mock('containers/CoursesPanel', () => 'CoursesPanel');
jest.mock('containers/WidgetContainers/LoadedSidebar', () => 'LoadedWidgetSidebar');
jest.mock('containers/WidgetContainers/NoCoursesSidebar', () => 'NoCoursesWidgetSidebar');
jest.mock('./LoadingView', () => 'LoadingView');
@@ -116,7 +116,7 @@ describe('Dashboard', () => {
showSelectSessionModal: true,
},
content: ['LoadedView', (
<DashboardLayout sidebar={LoadedWidgetSidebar}><CourseList /></DashboardLayout>
<DashboardLayout sidebar={LoadedWidgetSidebar}><CoursesPanel /></DashboardLayout>
)],
showEnterpriseModal: false,
showSelectSessionModal: true,
@@ -132,7 +132,7 @@ describe('Dashboard', () => {
showSelectSessionModal: false,
},
content: ['Dashboard layout with no courses sidebar and content', (
<DashboardLayout sidebar={NoCoursesWidgetSidebar}><CourseList /></DashboardLayout>
<DashboardLayout sidebar={NoCoursesWidgetSidebar}><CoursesPanel /></DashboardLayout>
)],
showEnterpriseModal: true,
showSelectSessionModal: false,

View File

@@ -29,6 +29,7 @@ describe('app simple selectors', () => {
keys.selectSessionModal,
keys.pageNumber,
keys.socialShareSettings,
keys.filters,
])('%s app simple selector forwards corresponding data from app store', (key) => {
testState = { app: { [key]: testString, otherField: 'fake string' } };
const { preSelectors, cb } = simpleSelectors[key];

View File

@@ -1,7 +1,7 @@
import React from 'react';
import './index.scss';
import { reduxHooks } from 'hooks';
import NoCoursesView from 'containers/CourseList/NoCoursesView';
import NoCoursesView from 'containers/CoursesPanel/NoCoursesView';
import LoadingView from './components/LoadingView';
import LoadedView from './components/LoadedView';
import hooks from './hooks';

View File

@@ -5,7 +5,7 @@ import hooks from './hooks';
import ProductRecommendations from './index';
import LoadingView from './components/LoadingView';
import LoadedView from './components/LoadedView';
import NoCoursesView from '../../containers/CourseList/NoCoursesView';
import NoCoursesView from '../../containers/CoursesPanel/NoCoursesView';
import { mockCrossProductResponse, mockAmplitudeResponse } from './testData';
jest.mock('./hooks', () => ({
@@ -21,7 +21,7 @@ jest.mock('hooks', () => ({
jest.mock('./components/LoadingView', () => 'LoadingView');
jest.mock('./components/LoadedView', () => 'LoadedView');
jest.mock('containers/CourseList/NoCoursesView', () => 'NoCoursesView');
jest.mock('containers/CoursesPanel/NoCoursesView', () => 'NoCoursesView');
describe('ProductRecommendations', () => {
const defaultValues = {