feat: pagination studio home for courses

This commit is contained in:
Jhon Vente
2024-02-05 16:21:16 -05:00
parent 9c52b8b6c5
commit 94be906dda
15 changed files with 237 additions and 94 deletions

View File

@@ -12,6 +12,7 @@ module.exports = {
rerunLink: '/course_rerun/course-v1:MachineLearning+123+2023',
run: '2023',
url: '/course/course-v1:MachineLearning+123+2023',
cmsLink: '//localhost:18010/courses/course-v1:MachineLearning+123+2023',
},
{
courseKey: 'course-v1:Design+123+e.g.2025',
@@ -22,6 +23,7 @@ module.exports = {
rerunLink: '/course_rerun/course-v1:Design+123+e.g.2025',
run: 'e.g.2025',
url: '/course/course-v1:Design+123+e.g.2025',
cmsLink: '//localhost:18010/courses/course-v1:Design+123+e.g.2025',
},
],
canCreateOrganizations: true,

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp, getConfig } from '@edx/frontend-platform';
@@ -45,13 +45,17 @@ describe('<CardItem />', () => {
});
it('should render correct links for non-library course', () => {
const props = studioHomeMock.archivedCourses[0];
const { getByText } = render(<RootWrapper {...props} />);
const { getByText, getByTestId } = render(<RootWrapper {...props} />);
const courseTitleLink = getByText(props.displayName);
expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`);
const dropDownMenu = getByTestId('toggle-dropdown');
fireEvent.click(dropDownMenu);
const btnReRunCourse = getByText(messages.btnReRunText.defaultMessage);
expect(btnReRunCourse).toHaveAttribute('href', props.rerunLink);
const viewLiveLink = getByText(messages.viewLiveBtnText.defaultMessage);
expect(viewLiveLink).toHaveAttribute('href', props.lmsLink);
const editInStudioLink = getByText(messages.editStudioBtnText.defaultMessage);
expect(editInStudioLink).toHaveAttribute('href', props.cmsLink);
});
it('should render course details for library course', () => {
const props = { ...studioHomeMock.archivedCourses[0], isLibraries: true };

View File

@@ -1,7 +1,13 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { ActionRow, Card, Hyperlink } from '@edx/paragon';
import {
Card,
Hyperlink,
Dropdown,
IconButton,
} from '@edx/paragon';
import { MoreHoriz } from '@edx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
@@ -10,7 +16,16 @@ import { getStudioHomeData } from '../data/selectors';
import messages from '../messages';
const CardItem = ({
intl, displayName, lmsLink, rerunLink, org, number, run, isLibraries, url,
intl,
displayName,
lmsLink,
rerunLink,
org,
number,
run,
isLibraries,
url,
cmsLink,
}) => {
const {
allowCourseReruns,
@@ -41,16 +56,29 @@ const CardItem = ({
)}
subtitle={subtitle}
actions={showActions && (
<ActionRow>
{isShowRerunLink && (
<Hyperlink className="small" destination={rerunLink}>
{intl.formatMessage(messages.btnReRunText)}
</Hyperlink>
)}
<Hyperlink className="small ml-3" destination={lmsLink}>
{intl.formatMessage(messages.viewLiveBtnText)}
</Hyperlink>
</ActionRow>
<Dropdown>
<Dropdown.Toggle
as={IconButton}
iconAs={MoreHoriz}
variant="primary"
data-testid="toggle-dropdown"
/>
<Dropdown.Menu>
{isShowRerunLink && (
<Dropdown.Item href={rerunLink}>
{messages.btnReRunText.defaultMessage}
</Dropdown.Item>
)}
<Dropdown.Item href={lmsLink}>
{intl.formatMessage(messages.viewLiveBtnText)}
</Dropdown.Item>
<Dropdown.Item href={cmsLink}>
{intl.formatMessage(messages.editStudioBtnText)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
)}
/>
</Card>
@@ -62,12 +90,14 @@ CardItem.defaultProps = {
rerunLink: '',
lmsLink: '',
run: '',
cmsLink: '',
};
CardItem.propTypes = {
intl: intlShape.isRequired,
displayName: PropTypes.string.isRequired,
lmsLink: PropTypes.string,
cmsLink: PropTypes.string,
rerunLink: PropTypes.string,
org: PropTypes.string.isRequired,
run: PropTypes.string,

View File

@@ -16,8 +16,8 @@ export async function getStudioHomeData() {
return camelCaseObject(data);
}
export async function getStudioHomeCourses(search) {
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v1/home/courses${search}`);
export async function getStudioHomeCourses(search, customParams) {
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v2/home/courses${search}`, { params: customParams });
return camelCaseObject(data);
}

View File

@@ -44,7 +44,7 @@ describe('studio-home api calls', () => {
});
fit('should get studio courses data', async () => {
const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`;
const apiLink = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`;
axiosMock.onGet(apiLink).reply(200, generateGetStudioCoursesApiResponse());
const result = await getStudioHomeCourses('');
const expected = generateGetStudioCoursesApiResponse();

View File

@@ -1,3 +1,4 @@
export const getStudioHomeData = state => state.studioHome.studioHomeData;
export const getLoadingStatuses = (state) => state.studioHome.loadingStatuses;
export const getSavingStatuses = (state) => state.studioHome.savingStatuses;
export const getStudioHomeCoursesParams = (state) => state.studioHome.studioHomeCoursesCustomParams;

View File

@@ -17,6 +17,9 @@ const slice = createSlice({
deleteNotificationSavingStatus: '',
},
studioHomeData: {},
studioHomeCoursesCustomParams: {
currentPage: 1,
},
},
reducers: {
updateLoadingStatuses: (state, { payload }) => {
@@ -29,15 +32,21 @@ const slice = createSlice({
Object.assign(state.studioHomeData, payload);
},
fetchCourseDataSuccess: (state, { payload }) => {
const { courses, archivedCourses, inProcessCourseActions } = payload;
const { courses, archivedCourses = [], inProcessCourseActions } = payload.results;
const { numPages, count } = payload;
state.studioHomeData.courses = courses;
state.studioHomeData.archivedCourses = archivedCourses;
state.studioHomeData.inProcessCourseActions = inProcessCourseActions;
state.studioHomeData.numPages = numPages;
state.studioHomeData.coursesCount = count;
},
fetchLibraryDataSuccess: (state, { payload }) => {
const { libraries } = payload;
state.studioHomeData.libraries = libraries;
},
updateStudioHomeCoursesCustomParams: (state, { payload }) => {
state.studioHomeCoursesCustomParams = { ...state.studioHomeCoursesCustomParams, ...payload };
},
},
});
@@ -47,6 +56,7 @@ export const {
fetchStudioHomeDataSuccess,
fetchCourseDataSuccess,
fetchLibraryDataSuccess,
updateStudioHomeCoursesCustomParams,
} = slice.actions;
export const {

View File

@@ -14,7 +14,7 @@ import {
fetchLibraryDataSuccess,
} from './slice';
function fetchStudioHomeData(search, hasHomeData) {
function fetchStudioHomeData(search, hasHomeData, customParams = {}) {
return async (dispatch) => {
dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.IN_PROGRESS }));
dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.IN_PROGRESS }));
@@ -30,7 +30,7 @@ function fetchStudioHomeData(search, hasHomeData) {
}
}
try {
const coursesData = await getStudioHomeCourses(search || '');
const coursesData = await getStudioHomeCourses(search || '', customParams);
dispatch(fetchCourseDataSuccess(coursesData));
dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.SUCCESSFUL }));
} catch (error) {

View File

@@ -15,6 +15,9 @@ export const initialState = {
deleteNotificationSavingStatus: '',
},
studioHomeData: {},
studioHomeCoursesCustomParams: {
currentPage: 1,
},
},
};
@@ -46,51 +49,58 @@ export const generateGetStudioHomeDataApiResponse = () => ({
});
export const generateGetStudioCoursesApiResponse = () => ({
archivedCourses: [
{
courseKey: 'course-v1:MachineLearning+123+2023',
displayName: 'Machine Learning',
lmsLink: '//localhost:18000/courses/course-v1:MachineLearning+123+2023/jump_to/block-v1:MachineLearning+123+2023+type@course+block@course',
number: '123',
org: 'LSE',
rerunLink: '/course_rerun/course-v1:MachineLearning+123+2023',
run: '2023',
url: '/course/course-v1:MachineLearning+123+2023',
},
{
courseKey: 'course-v1:Design+123+e.g.2025',
displayName: 'Design',
lmsLink: '//localhost:18000/courses/course-v1:Design+123+e.g.2025/jump_to/block-v1:Design+123+e.g.2025+type@course+block@course',
number: '123',
org: 'University of Cape Town',
rerunLink: '/course_rerun/course-v1:Design+123+e.g.2025',
run: 'e.g.2025',
url: '/course/course-v1:Design+123+e.g.2025',
},
],
courses: [
{
courseKey: 'course-v1:HarvardX+123+2023',
displayName: 'Managing Risk in the Information Age',
lmsLink: '//localhost:18000/courses/course-v1:HarvardX+123+2023/jump_to/block-v1:HarvardX+123+2023+type@course+block@course',
number: '123',
org: 'HarvardX',
rerunLink: '/course_rerun/course-v1:HarvardX+123+2023',
run: '2023',
url: '/course/course-v1:HarvardX+123+2023',
},
{
courseKey: 'org.0/course_0/Run_0',
displayName: 'Run 0',
lmsLink: null,
number: 'course_0',
org: 'org.0',
rerunLink: null,
run: 'Run_0',
url: null,
},
],
inProcessCourseActions: [],
count: 5,
next: null,
previous: null,
numPages: 2,
results: {
archivedCourses: [
{
courseKey: 'course-v1:MachineLearning+123+2023',
displayName: 'Machine Learning',
lmsLink: '//localhost:18000/courses/course-v1:MachineLearning+123+2023/jump_to/block-v1:MachineLearning+123+2023+type@course+block@course',
number: '123',
org: 'LSE',
rerunLink: '/course_rerun/course-v1:MachineLearning+123+2023',
run: '2023',
url: '/course/course-v1:MachineLearning+123+2023',
},
{
courseKey: 'course-v1:Design+123+e.g.2025',
displayName: 'Design',
lmsLink: '//localhost:18000/courses/course-v1:Design+123+e.g.2025/jump_to/block-v1:Design+123+e.g.2025+type@course+block@course',
number: '123',
org: 'University of Cape Town',
rerunLink: '/course_rerun/course-v1:Design+123+e.g.2025',
run: 'e.g.2025',
url: '/course/course-v1:Design+123+e.g.2025',
},
],
courses: [
{
courseKey: 'course-v1:HarvardX+123+2023',
displayName: 'Managing Risk in the Information Age',
lmsLink: '//localhost:18000/courses/course-v1:HarvardX+123+2023/jump_to/block-v1:HarvardX+123+2023+type@course+block@course',
number: '123',
org: 'HarvardX',
rerunLink: '/course_rerun/course-v1:HarvardX+123+2023',
run: '2023',
url: '/course/course-v1:HarvardX+123+2023',
},
{
courseKey: 'org.0/course_0/Run_0',
displayName: 'Run 0',
lmsLink: null,
number: 'course_0',
org: 'org.0',
rerunLink: null,
run: 'Run_0',
url: null,
},
],
inProcessCourseActions: [],
},
});
export const generateGetStuioHomeLibrariesApiResponse = () => ({

View File

@@ -10,6 +10,7 @@ import {
getLoadingStatuses,
getSavingStatuses,
getStudioHomeData,
getStudioHomeCoursesParams,
} from './data/selectors';
import { updateSavingStatuses } from './data/slice';
@@ -17,6 +18,7 @@ const useStudioHome = () => {
const location = useLocation();
const dispatch = useDispatch();
const studioHomeData = useSelector(getStudioHomeData);
const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams);
const newCourseData = useSelector(getCourseData);
const { studioHomeLoadingStatus } = useSelector(getLoadingStatuses);
const savingCreateRerunStatus = useSelector(getSavingStatus);
@@ -33,6 +35,11 @@ const useStudioHome = () => {
setShowNewCourseContainer(false);
}, [location.search]);
useEffect(() => {
const { currentPage } = studioHomeCoursesParams;
dispatch(fetchStudioHomeData(location.search ?? '', false, { page: currentPage }));
}, [studioHomeCoursesParams.currentPage]);
useEffect(() => {
if (courseCreatorSavingStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateSavingStatuses({ courseCreatorSavingStatus: '' }));

View File

@@ -49,6 +49,10 @@ const messages = defineMessages({
id: 'course-authoring.studio-home.btn.view-live.text',
defaultMessage: 'View live',
},
editStudioBtnText: {
id: 'course-authoring.studio-home.btn.edit.studio.text',
defaultMessage: 'Edit in Studio',
},
organizationTitle: {
id: 'course-authoring.studio-home.organization.title',
defaultMessage: 'Organization and library settings',

View File

@@ -27,13 +27,15 @@ const { studioShortName } = studioHomeMock;
let axiosMock;
let store;
const courseApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`;
const courseApiLink = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`;
const libraryApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`;
const mockDispatch = jest.fn();
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<TabsSection intl={{ formatMessage: jest.fn() }} dispatch={jest.fn()} />
<TabsSection intl={{ formatMessage: jest.fn() }} dispatch={mockDispatch} />
</IntlProvider>
</AppProvider>
);
@@ -92,7 +94,7 @@ describe('<TabsSection />', () => {
it('should render default sections when courses are empty', async () => {
const data = generateGetStudioCoursesApiResponse();
data.courses = [];
data.results.courses = [];
render(<RootWrapper />);
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
@@ -116,6 +118,36 @@ describe('<TabsSection />', () => {
expect(screen.getByText(tabMessages.courseTabErrorMessage.defaultMessage)).toBeVisible();
});
it('should render pagination when there are courses', async () => {
render(<RootWrapper />);
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLink).reply(200, generateGetStudioCoursesApiResponse());
await executeThunk(fetchStudioHomeData(), store.dispatch);
const data = generateGetStudioCoursesApiResponse();
const coursesLength = data.results.courses.length;
const totalItems = data.count;
const paginationInfoText = `Showing ${coursesLength} of ${totalItems}`;
expect(screen.getByText(studioHomeMock.courses[0].displayName)).toBeVisible();
const pagination = screen.getByRole('navigation');
const paginationInfo = screen.getByTestId('pagination-info');
expect(paginationInfo.textContent).toContain(paginationInfoText);
expect(pagination).toBeVisible();
});
it('should not render pagination when there are not courses', async () => {
const data = generateGetStudioCoursesApiResponse();
data.results.courses = [];
render(<RootWrapper />);
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLink).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
const pagination = screen.queryByRole('navigation');
expect(pagination).not.toBeInTheDocument();
});
});
describe('archived tab', () => {
@@ -137,7 +169,7 @@ describe('<TabsSection />', () => {
it('should hide Archived tab when archived courses are empty', async () => {
const data = generateGetStudioCoursesApiResponse();
data.archivedCourses = [];
data.results.archivedCourses = [];
render(<RootWrapper />);
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());

View File

@@ -2,11 +2,12 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, Row } from '@edx/paragon';
import { Icon, Row, Pagination } from '@edx/paragon';
import { Error } from '@edx/paragon/icons';
import { COURSE_CREATOR_STATES } from '../../../constants';
import { getStudioHomeData } from '../../data/selectors';
import { getStudioHomeData, getStudioHomeCoursesParams } from '../../data/selectors';
import { updateStudioHomeCoursesCustomParams } from '../../data/slice';
import CardItem from '../../card-item';
import CollapsibleStateWithAction from '../../collapsible-state-with-action';
import { sortAlphabeticallyArray } from '../utils';
@@ -23,12 +24,16 @@ const CoursesTab = ({
isShowProcessing,
isLoading,
isFailed,
dispatch,
numPages,
coursesCount,
}) => {
const intl = useIntl();
const {
courseCreatorStatus,
optimizationEnabled,
} = useSelector(getStudioHomeData);
const { currentPage } = useSelector(getStudioHomeCoursesParams);
const hasAbilityToCreateCourse = courseCreatorStatus === COURSE_CREATOR_STATES.granted;
const showCollapsible = [
COURSE_CREATOR_STATES.denied,
@@ -36,6 +41,9 @@ const CoursesTab = ({
COURSE_CREATOR_STATES.unrequested,
].includes(courseCreatorStatus);
const handlePageSelected = (page) => dispatch(updateStudioHomeCoursesCustomParams({ currentPage: page }));
const hasCourses = coursesDataItems?.length;
if (isLoading) {
return (
<Row className="m-0 mt-4 justify-content-center">
@@ -58,30 +66,54 @@ const CoursesTab = ({
) : (
<>
{isShowProcessing && <ProcessingCourses />}
{coursesDataItems?.length ? (
sortAlphabeticallyArray(coursesDataItems).map(
({
courseKey,
displayName,
lmsLink,
org,
rerunLink,
number,
run,
url,
}) => (
<CardItem
key={courseKey}
displayName={displayName}
lmsLink={lmsLink}
rerunLink={rerunLink}
org={org}
number={number}
run={run}
url={url}
{hasCourses && (
<div className="d-flex justify-content-end">
<p data-testid="pagination-info">
{intl.formatMessage(messages.coursesPaginationInfo, {
length: coursesDataItems.length,
total: coursesCount,
})}
</p>
</div>
)}
{hasCourses ? (
<>
{sortAlphabeticallyArray(coursesDataItems).map(
({
courseKey,
displayName,
lmsLink,
org,
rerunLink,
number,
run,
url,
cmsLink,
}) => (
<CardItem
key={courseKey}
displayName={displayName}
lmsLink={lmsLink}
rerunLink={rerunLink}
org={org}
number={number}
run={run}
url={url}
cmsLink={cmsLink}
/>
),
)}
{numPages > 1 && (
<Pagination
className="d-flex justify-content-center"
paginationLabel="pagination navigation"
pageCount={numPages}
currentPage={currentPage}
onPageSelect={handlePageSelected}
/>
),
)
)}
</>
) : (!optimizationEnabled && (
<ContactAdministrator
hasAbilityToCreateCourse={hasAbilityToCreateCourse}
@@ -119,6 +151,9 @@ CoursesTab.propTypes = {
isShowProcessing: PropTypes.bool.isRequired,
isLoading: PropTypes.bool.isRequired,
isFailed: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
numPages: PropTypes.number.isRequired,
coursesCount: PropTypes.number.isRequired,
};
export default CoursesTab;

View File

@@ -25,6 +25,7 @@ const TabsSection = ({
libraryAuthoringMfeUrl,
redirectToLibraryAuthoringMfe,
courses, librariesEnabled, libraries, archivedCourses,
numPages, coursesCount,
} = useSelector(getStudioHomeData);
const {
courseLoadingStatus,
@@ -52,6 +53,9 @@ const TabsSection = ({
isShowProcessing={isShowProcessing}
isLoading={isLoadingCourses}
isFailed={isFailedCoursesPage}
dispatch={dispatch}
numPages={numPages}
coursesCount={coursesCount}
/>
</Tab>,
);

View File

@@ -9,6 +9,10 @@ const messages = defineMessages({
id: 'course-authoring.studio-home.courses.tab.error.message',
defaultMessage: 'Failed to fetch courses. Please try again later.',
},
coursesPaginationInfo: {
id: 'course-authoring.studio-home.courses.pagination.info',
defaultMessage: 'Showing {length} of {total}',
},
librariesTabErrorMessage: {
id: 'course-authoring.studio-home.libraries.tab.error.message',
defaultMessage: 'Failed to fetch libraries. Please try again later.',