feat: adding feature for pagination

This commit is contained in:
Jhon Vente
2024-02-23 07:26:27 -05:00
parent 6d6bd12e68
commit f31cae24ea
16 changed files with 192 additions and 40 deletions

1
.env
View File

@@ -40,3 +40,4 @@ HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
AI_TRANSLATIONS_BASE_URL=''
ENABLE_PAGINATION_COURSES_STUDIO_HOME='true'

View File

@@ -42,3 +42,4 @@ HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
ENABLE_PAGINATION_COURSES_STUDIO_HOME='true'

View File

@@ -34,3 +34,4 @@ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_PAGINATION_COURSES_STUDIO_HOME='true'

View File

@@ -119,6 +119,7 @@ initialize({
ENABLE_UNIT_PAGE: process.env.ENABLE_UNIT_PAGE || 'false',
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false',
ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false',
ENABLE_PAGINATION_COURSES_STUDIO_HOME: process.env.ENABLE_PAGINATION_COURSES_STUDIO_HOME || 'false',
}, 'CourseAuthoringConfig');
},
},

View File

@@ -49,6 +49,8 @@ const StudioHome = ({ intl }) => {
redirectToLibraryAuthoringMfe,
} = studioHomeData;
const isPaginationCoursesEnabled = getConfig().ENABLE_PAGINATION_COURSES_STUDIO_HOME === 'true';
function getHeaderButtons() {
const headerButtons = [];
@@ -139,6 +141,7 @@ const StudioHome = ({ intl }) => {
onClickNewCourse={() => setShowNewCourseContainer(true)}
isShowProcessing={isShowProcessing}
dispatch={dispatch}
isPaginationCoursesEnabled={isPaginationCoursesEnabled}
/>
</section>
</Layout.Element>

View File

@@ -43,9 +43,21 @@ describe('<CardItem />', () => {
const { getByText } = render(<RootWrapper {...props} />);
expect(getByText(`${props.org} / ${props.number} / ${props.run}`)).toBeInTheDocument();
});
it('should render correct links for non-library course', () => {
const props = studioHomeMock.archivedCourses[0];
const { getByText, getByTestId } = render(<RootWrapper {...props} />);
const { getByText } = render(<RootWrapper {...props} />);
const courseTitleLink = getByText(props.displayName);
expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`);
const btnReRunCourse = getByText(messages.btnReRunText.defaultMessage);
expect(btnReRunCourse).toHaveAttribute('href', props.rerunLink);
const viewLiveLink = getByText(messages.viewLiveBtnText.defaultMessage);
expect(viewLiveLink).toHaveAttribute('href', props.lmsLink);
});
it('should render correct links for non-library course pagination', () => {
const props = studioHomeMock.archivedCourses[0];
const { getByText, getByTestId } = render(<RootWrapper {...props} isPaginationEnabled />);
const courseTitleLink = getByText(props.displayName);
expect(courseTitleLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${props.url}`);
const dropDownMenu = getByTestId('toggle-dropdown');

View File

@@ -6,6 +6,7 @@ import {
Hyperlink,
Dropdown,
IconButton,
ActionRow,
} from '@openedx/paragon';
import { MoreHoriz } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -24,6 +25,8 @@ const CardItem = ({
number,
run,
isLibraries,
courseKey,
isPaginationEnabled,
url,
cmsLink,
}) => {
@@ -56,27 +59,48 @@ const CardItem = ({
)}
subtitle={subtitle}
actions={showActions && (
<Dropdown>
<Dropdown.Toggle
as={IconButton}
iconAs={MoreHoriz}
variant="primary"
data-testid="toggle-dropdown"
/>
<Dropdown.Menu>
{isShowRerunLink && (
<Dropdown.Item href={rerunLink}>
{messages.btnReRunText.defaultMessage}
isPaginationEnabled ? (
<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>
) : (
<ActionRow>
{isShowRerunLink && (
<Hyperlink
className="small"
destination={rerunLink}
key={`action-row-rerunLink-${courseKey}`}
>
{intl.formatMessage(messages.btnReRunText)}
</Hyperlink>
)}
<Dropdown.Item href={lmsLink}>
<Hyperlink
className="small ml-3"
destination={lmsLink}
key={`action-row-lmsLink-${courseKey}`}
>
{intl.formatMessage(messages.viewLiveBtnText)}
</Dropdown.Item>
<Dropdown.Item href={cmsLink}>
{intl.formatMessage(messages.editStudioBtnText)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Hyperlink>
</ActionRow>
)
)}
/>
</Card>
@@ -85,6 +109,8 @@ const CardItem = ({
CardItem.defaultProps = {
isLibraries: false,
isPaginationEnabled: false,
courseKey: '',
rerunLink: '',
lmsLink: '',
run: '',
@@ -102,6 +128,8 @@ CardItem.propTypes = {
number: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
isLibraries: PropTypes.bool,
courseKey: PropTypes.string,
isPaginationEnabled: PropTypes.bool,
};
export default injectIntl(CardItem);

View File

@@ -16,6 +16,10 @@ export async function getStudioHomeData() {
return camelCaseObject(data);
}
export async function getStudioHomeCourses(search) {
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v1/home/courses${search}`);
return camelCaseObject(data);
}
/**
* Get's studio home courses.
* @param {string} search - Query string parameters for filtering the courses.
@@ -25,7 +29,7 @@ export async function getStudioHomeData() {
* Features such as pagination, filtering, and ordering are better handled in the new version.
* Please refer to this PR for further details: https://github.com/openedx/edx-platform/pull/34173
*/
export async function getStudioHomeCourses(search, customParams) {
export async function getStudioHomeCoursesV2(search, customParams) {
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v2/home/courses${search}`, { params: customParams });
return camelCaseObject(data);
}

View File

@@ -11,6 +11,7 @@ import {
sendRequestForCourseCreator,
getApiBaseUrl,
getStudioHomeCourses,
getStudioHomeCoursesV2,
getStudioHomeLibraries,
} from './api';
import { generateGetStudioCoursesApiResponse, generateGetStudioHomeDataApiResponse, generateGetStuioHomeLibrariesApiResponse } from '../factories/mockApiResponses';
@@ -44,7 +45,7 @@ describe('studio-home api calls', () => {
});
fit('should get studio courses data', async () => {
const apiLink = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`;
const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`;
axiosMock.onGet(apiLink).reply(200, generateGetStudioCoursesApiResponse());
const result = await getStudioHomeCourses('');
const expected = generateGetStudioCoursesApiResponse();
@@ -53,6 +54,16 @@ describe('studio-home api calls', () => {
expect(result).toEqual(expected);
});
fit('should get studio courses data v2', async () => {
const apiLink = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`;
axiosMock.onGet(apiLink).reply(200, generateGetStudioCoursesApiResponse());
const result = await getStudioHomeCoursesV2('');
const expected = generateGetStudioCoursesApiResponse();
expect(axiosMock.history.get[0].url).toEqual(apiLink);
expect(result).toEqual(expected);
});
it('should get studio libraries data', async () => {
const apiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`;
axiosMock.onGet(apiLink).reply(200, generateGetStuioHomeLibrariesApiResponse());

View File

@@ -32,6 +32,12 @@ const slice = createSlice({
Object.assign(state.studioHomeData, payload);
},
fetchCourseDataSuccess: (state, { payload }) => {
const { courses, archivedCourses, inProcessCourseActions } = payload;
state.studioHomeData.courses = courses;
state.studioHomeData.archivedCourses = archivedCourses;
state.studioHomeData.inProcessCourseActions = inProcessCourseActions;
},
fetchCourseDataSuccessV2: (state, { payload }) => {
const { courses, archivedCourses = [], inProcessCourseActions } = payload.results;
const { numPages, count } = payload;
state.studioHomeData.courses = courses;
@@ -56,6 +62,7 @@ export const {
updateLoadingStatuses,
fetchStudioHomeDataSuccess,
fetchCourseDataSuccess,
fetchCourseDataSuccessV2,
fetchLibraryDataSuccess,
updateStudioHomeCoursesCustomParams,
} = slice.actions;

View File

@@ -5,6 +5,7 @@ import {
handleCourseNotification,
getStudioHomeCourses,
getStudioHomeLibraries,
getStudioHomeCoursesV2,
} from './api';
import {
fetchStudioHomeDataSuccess,
@@ -12,9 +13,10 @@ import {
updateLoadingStatuses,
updateSavingStatuses,
fetchLibraryDataSuccess,
fetchCourseDataSuccessV2,
} from './slice';
function fetchStudioHomeData(search, hasHomeData, customParams = {}) {
function fetchStudioHomeData(search, hasHomeData, customParams = {}, isPaginationEnabled = false) {
return async (dispatch) => {
dispatch(updateLoadingStatuses({ studioHomeLoadingStatus: RequestStatus.IN_PROGRESS }));
dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.IN_PROGRESS }));
@@ -30,8 +32,14 @@ function fetchStudioHomeData(search, hasHomeData, customParams = {}) {
}
}
try {
const coursesData = await getStudioHomeCourses(search || '', customParams);
dispatch(fetchCourseDataSuccess(coursesData));
if (isPaginationEnabled) {
const coursesData = await getStudioHomeCoursesV2(search || '', customParams);
dispatch(fetchCourseDataSuccessV2(coursesData));
} else {
const coursesData = await getStudioHomeCourses(search || '');
dispatch(fetchCourseDataSuccess(coursesData));
}
dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateLoadingStatuses({ courseLoadingStatus: RequestStatus.FAILED }));

View File

@@ -49,6 +49,54 @@ 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: [],
});
export const generateGetStudioCoursesApiResponseV2 = () => ({
count: 5,
next: null,
previous: null,

View File

@@ -37,7 +37,7 @@ const useStudioHome = () => {
useEffect(() => {
const { currentPage } = studioHomeCoursesParams;
dispatch(fetchStudioHomeData(location.search ?? '', false, { page: currentPage }));
dispatch(fetchStudioHomeData(location.search ?? '', false, { page: currentPage }, true));
}, [studioHomeCoursesParams.currentPage]);
useEffect(() => {

View File

@@ -17,6 +17,7 @@ import {
initialState,
generateGetStudioHomeDataApiResponse,
generateGetStudioCoursesApiResponse,
generateGetStudioCoursesApiResponseV2,
generateGetStuioHomeLibrariesApiResponse,
} from '../factories/mockApiResponses';
import { getApiBaseUrl, getStudioHomeApiUrl } from '../data/api';
@@ -27,15 +28,21 @@ const { studioShortName } = studioHomeMock;
let axiosMock;
let store;
const courseApiLink = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`;
const courseApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/courses`;
const courseApiLinkV2 = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`;
const libraryApiLink = `${getApiBaseUrl()}/api/contentstore/v1/home/libraries`;
const mockDispatch = jest.fn();
const RootWrapper = () => (
const RootWrapper = (overrideProps) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<TabsSection intl={{ formatMessage: jest.fn() }} dispatch={mockDispatch} />
<TabsSection
intl={{ formatMessage: jest.fn() }}
dispatch={mockDispatch}
isPaginationCoursesEnabled={false}
{...overrideProps}
/>
</IntlProvider>
</AppProvider>
);
@@ -94,7 +101,7 @@ describe('<TabsSection />', () => {
it('should render default sections when courses are empty', async () => {
const data = generateGetStudioCoursesApiResponse();
data.results.courses = [];
data.courses = [];
render(<RootWrapper />);
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
@@ -120,11 +127,11 @@ describe('<TabsSection />', () => {
});
it('should render pagination when there are courses', async () => {
render(<RootWrapper />);
render(<RootWrapper isPaginationCoursesEnabled />);
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLink).reply(200, generateGetStudioCoursesApiResponse());
await executeThunk(fetchStudioHomeData(), store.dispatch);
const data = generateGetStudioCoursesApiResponse();
axiosMock.onGet(courseApiLinkV2).reply(200, generateGetStudioCoursesApiResponseV2());
await executeThunk(fetchStudioHomeData('', true, {}, true), store.dispatch);
const data = generateGetStudioCoursesApiResponseV2();
const coursesLength = data.results.courses.length;
const totalItems = data.count;
const paginationInfoText = `Showing ${coursesLength} of ${totalItems}`;
@@ -138,11 +145,11 @@ describe('<TabsSection />', () => {
});
it('should not render pagination when there are not courses', async () => {
const data = generateGetStudioCoursesApiResponse();
const data = generateGetStudioCoursesApiResponseV2();
data.results.courses = [];
render(<RootWrapper />);
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
axiosMock.onGet(courseApiLink).reply(200, data);
axiosMock.onGet(courseApiLinkV2).reply(200, data);
await executeThunk(fetchStudioHomeData(), store.dispatch);
const pagination = screen.queryByRole('navigation');

View File

@@ -27,6 +27,7 @@ const CoursesTab = ({
dispatch,
numPages,
coursesCount,
isEnabledPagination,
}) => {
const intl = useIntl();
const {
@@ -66,7 +67,7 @@ const CoursesTab = ({
) : (
<>
{isShowProcessing && <ProcessingCourses />}
{hasCourses && (
{hasCourses && isEnabledPagination && (
<div className="d-flex justify-content-end">
<p data-testid="pagination-info">
{intl.formatMessage(messages.coursesPaginationInfo, {
@@ -100,11 +101,12 @@ const CoursesTab = ({
run={run}
url={url}
cmsLink={cmsLink}
isPaginated={isEnabledPagination}
/>
),
)}
{numPages > 1 && (
{numPages > 1 && isEnabledPagination && (
<Pagination
className="d-flex justify-content-center"
paginationLabel="pagination navigation"
@@ -133,6 +135,12 @@ const CoursesTab = ({
);
};
CoursesTab.defaultProps = {
numPages: 0,
coursesCount: 0,
isEnabledPagination: false,
};
CoursesTab.propTypes = {
coursesDataItems: PropTypes.arrayOf(
PropTypes.shape({
@@ -152,8 +160,9 @@ CoursesTab.propTypes = {
isLoading: PropTypes.bool.isRequired,
isFailed: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
numPages: PropTypes.number.isRequired,
coursesCount: PropTypes.number.isRequired,
numPages: PropTypes.number,
coursesCount: PropTypes.number,
isEnabledPagination: PropTypes.bool,
};
export default CoursesTab;

View File

@@ -13,7 +13,12 @@ import { RequestStatus } from '../../data/constants';
import { fetchLibraryData } from '../data/thunks';
const TabsSection = ({
intl, showNewCourseContainer, onClickNewCourse, isShowProcessing, dispatch,
intl,
showNewCourseContainer,
onClickNewCourse,
isShowProcessing,
dispatch,
isPaginationCoursesEnabled,
}) => {
const TABS_LIST = {
courses: 'courses',
@@ -56,6 +61,7 @@ const TabsSection = ({
dispatch={dispatch}
numPages={numPages}
coursesCount={coursesCount}
isEnabledPagination={isPaginationCoursesEnabled}
/>
</Tab>,
);
@@ -118,12 +124,17 @@ const TabsSection = ({
);
};
TabsSection.defaultProps = {
isPaginationCoursesEnabled: false,
};
TabsSection.propTypes = {
intl: intlShape.isRequired,
showNewCourseContainer: PropTypes.bool.isRequired,
onClickNewCourse: PropTypes.func.isRequired,
isShowProcessing: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
isPaginationCoursesEnabled: PropTypes.bool,
};
export default injectIntl(TabsSection);