diff --git a/src/generic/Loading.jsx b/src/generic/Loading.jsx index 64abd9acec..8262e89b33 100644 --- a/src/generic/Loading.jsx +++ b/src/generic/Loading.jsx @@ -1,12 +1,14 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { Spinner } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -export const LoadingSpinner = () => ( +export const LoadingSpinner = ({ size }) => ( ( /> ); +LoadingSpinner.defaultProps = { + size: undefined, +}; + +LoadingSpinner.propTypes = { + size: PropTypes.string, +}; + const Loading = () => (
diff --git a/src/studio-home/StudioHome.jsx b/src/studio-home/StudioHome.jsx index 0b2d5b39de..8348aaca34 100644 --- a/src/studio-home/StudioHome.jsx +++ b/src/studio-home/StudioHome.jsx @@ -38,6 +38,7 @@ const StudioHome = ({ intl }) => { showNewCourseContainer, isShowOrganizationDropdown, hasAbilityToCreateNewCourse, + isFiltered, setShowNewCourseContainer, dispatch, } = useStudioHome(isPaginationCoursesEnabled); @@ -99,7 +100,7 @@ const StudioHome = ({ intl }) => { } const headerButtons = userIsActive ? getHeaderButtons() : []; - if (isLoadingPage) { + if (isLoadingPage && !isFiltered) { return (); } @@ -138,7 +139,7 @@ const StudioHome = ({ intl }) => { tabsData={studioHomeData} showNewCourseContainer={showNewCourseContainer} onClickNewCourse={() => setShowNewCourseContainer(true)} - isShowProcessing={isShowProcessing} + isShowProcessing={isShowProcessing && !isFiltered} dispatch={dispatch} isPaginationCoursesEnabled={isPaginationCoursesEnabled} /> diff --git a/src/studio-home/__mocks__/studioHomeMock.js b/src/studio-home/__mocks__/studioHomeMock.js index 0de8903c80..5385201e52 100644 --- a/src/studio-home/__mocks__/studioHomeMock.js +++ b/src/studio-home/__mocks__/studioHomeMock.js @@ -47,7 +47,7 @@ module.exports = { org: 'org.0', rerunLink: null, run: 'Run_0', - url: null, + url: '', }, ], inProcessCourseActions: [], diff --git a/src/studio-home/card-item/CardItem.test.jsx b/src/studio-home/card-item/CardItem.test.jsx index 20c4669ab0..dfd5d12df1 100644 --- a/src/studio-home/card-item/CardItem.test.jsx +++ b/src/studio-home/card-item/CardItem.test.jsx @@ -87,4 +87,20 @@ describe('', () => { expect(queryByText(messages.btnReRunText.defaultMessage)).not.toBeInTheDocument(); expect(queryByText(messages.viewLiveBtnText.defaultMessage)).not.toBeInTheDocument(); }); + + it('should render course key if displayname is empty', () => { + const props = studioHomeMock.courses[1]; + const courseKeyTest = 'course-key'; + const { getByText } = render( + , + ); + expect(getByText(courseKeyTest)).toBeInTheDocument(); + }); }); diff --git a/src/studio-home/card-item/index.jsx b/src/studio-home/card-item/index.jsx index fbffd20728..18c8bd5593 100644 --- a/src/studio-home/card-item/index.jsx +++ b/src/studio-home/card-item/index.jsx @@ -41,6 +41,7 @@ const CardItem = ({ const isShowRerunLink = allowCourseReruns && rerunCreatorStatus && courseCreatorStatus === COURSE_CREATOR_STATES.granted; + const hasDisplayName = displayName.trim().length ? displayName : courseKey; return ( @@ -51,7 +52,7 @@ const CardItem = ({ className="card-item-title" destination={courseUrl().toString()} > - {displayName} + {hasDisplayName} ) : ( {displayName} diff --git a/src/studio-home/data/api.js b/src/studio-home/data/api.js index e94d5f3f6d..1fefe2981a 100644 --- a/src/studio-home/data/api.js +++ b/src/studio-home/data/api.js @@ -1,4 +1,4 @@ -import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { camelCaseObject, snakeCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; @@ -30,7 +30,8 @@ export async function getStudioHomeCourses(search) { * Please refer to this PR for further details: https://github.com/openedx/edx-platform/pull/34173 */ export async function getStudioHomeCoursesV2(search, customParams) { - const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v2/home/courses${search}`, { params: customParams }); + const customParamsFormat = snakeCaseObject(customParams); + const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v2/home/courses${search}`, { params: customParamsFormat }); return camelCaseObject(data); } diff --git a/src/studio-home/data/slice.js b/src/studio-home/data/slice.js index b7e9f136cc..89ee211c0f 100644 --- a/src/studio-home/data/slice.js +++ b/src/studio-home/data/slice.js @@ -19,6 +19,12 @@ const slice = createSlice({ studioHomeData: {}, studioHomeCoursesRequestParams: { currentPage: 1, + search: undefined, + order: 'display_name', + archivedOnly: undefined, + activeOnly: undefined, + isFiltered: false, + cleanFilters: false, }, }, reducers: { @@ -51,8 +57,7 @@ const slice = createSlice({ state.studioHomeData.libraries = libraries; }, updateStudioHomeCoursesCustomParams: (state, { payload }) => { - const { currentPage } = payload; - state.studioHomeCoursesRequestParams.currentPage = currentPage; + Object.assign(state.studioHomeCoursesRequestParams, payload); }, }, }); diff --git a/src/studio-home/data/slice.test.js b/src/studio-home/data/slice.test.js index 3e7ccda6ae..7340e56793 100644 --- a/src/studio-home/data/slice.test.js +++ b/src/studio-home/data/slice.test.js @@ -1,4 +1,4 @@ -import { reducer, updateStudioHomeCoursesCustomParams } from './slice'; // Assuming the file is named slice.js +import { reducer, updateStudioHomeCoursesCustomParams } from './slice'; import { RequestStatus } from '../../data/constants'; @@ -17,6 +17,12 @@ describe('updateStudioHomeCoursesCustomParams action', () => { studioHomeData: {}, studioHomeCoursesRequestParams: { currentPage: 1, + search: undefined, + order: 'display_name', + archivedOnly: undefined, + activeOnly: undefined, + isFiltered: false, + cleanFilters: false, }, }; @@ -25,15 +31,28 @@ describe('updateStudioHomeCoursesCustomParams action', () => { expect(result).toEqual(initialState); }); - it('should update the currentPage in studioHomeCoursesRequestParams', () => { + it('should update the payload passed in studioHomeCoursesRequestParams', () => { const newState = { ...initialState, studioHomeCoursesRequestParams: { currentPage: 2, + search: 'test', + order: 'display_name', + archivedOnly: true, + activeOnly: true, + isFiltered: true, + cleanFilters: true, }, }; + const payload = { currentPage: 2, + search: 'test', + order: 'display_name', + archivedOnly: true, + activeOnly: true, + isFiltered: true, + cleanFilters: true, }; const result = reducer(initialState, updateStudioHomeCoursesCustomParams(payload)); diff --git a/src/studio-home/factories/mockApiResponses.jsx b/src/studio-home/factories/mockApiResponses.jsx index b292c28a58..ae110e8f6f 100644 --- a/src/studio-home/factories/mockApiResponses.jsx +++ b/src/studio-home/factories/mockApiResponses.jsx @@ -49,51 +49,35 @@ 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: { + 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 = () => ({ diff --git a/src/studio-home/hooks.jsx b/src/studio-home/hooks.jsx index c582f0ae1a..e596e18be0 100644 --- a/src/studio-home/hooks.jsx +++ b/src/studio-home/hooks.jsx @@ -10,6 +10,7 @@ import { getLoadingStatuses, getSavingStatuses, getStudioHomeData, + getStudioHomeCoursesParams, } from './data/selectors'; import { updateSavingStatuses } from './data/slice'; @@ -17,6 +18,8 @@ const useStudioHome = (isPaginated = false) => { const location = useLocation(); const dispatch = useDispatch(); const studioHomeData = useSelector(getStudioHomeData); + const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams); + const { isFiltered } = studioHomeCoursesParams; const newCourseData = useSelector(getCourseData); const { studioHomeLoadingStatus } = useSelector(getLoadingStatuses); const savingCreateRerunStatus = useSelector(getSavingStatus); @@ -89,6 +92,7 @@ const useStudioHome = (isPaginated = false) => { courseCreatorSavingStatus, isShowOrganizationDropdown, hasAbilityToCreateNewCourse, + isFiltered, dispatch, setShowNewCourseContainer, }; diff --git a/src/studio-home/processing-courses/index.jsx b/src/studio-home/processing-courses/index.jsx index 10fc02d1cf..67003659c6 100644 --- a/src/studio-home/processing-courses/index.jsx +++ b/src/studio-home/processing-courses/index.jsx @@ -13,7 +13,7 @@ const ProcessingCourses = () => { return ( <> -
+
{intl.formatMessage(messages.processingTitle)}

diff --git a/src/studio-home/tabs-section/TabsSection.test.jsx b/src/studio-home/tabs-section/TabsSection.test.jsx index 9b7fcfb6a9..686e2b8649 100644 --- a/src/studio-home/tabs-section/TabsSection.test.jsx +++ b/src/studio-home/tabs-section/TabsSection.test.jsx @@ -88,8 +88,9 @@ describe('', () => { describe('course tab', () => { it('should render specific course details', async () => { render(); + const { results: data } = generateGetStudioCoursesApiResponse(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); - axiosMock.onGet(courseApiLink).reply(200, generateGetStudioCoursesApiResponse()); + axiosMock.onGet(courseApiLink).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); expect(screen.getByText(studioHomeMock.courses[0].displayName)).toBeVisible(); @@ -101,7 +102,7 @@ describe('', () => { it('should render default sections when courses are empty', async () => { const data = generateGetStudioCoursesApiResponse(); - data.courses = []; + data.results.courses = []; render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); @@ -187,8 +188,10 @@ describe('', () => { describe('archived tab', () => { it('should switch to Archived tab and render specific archived course details', async () => { render(); + const { results: data } = generateGetStudioCoursesApiResponse(); + data.archivedCourses = studioHomeMock.archivedCourses; axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); - axiosMock.onGet(courseApiLink).reply(200, generateGetStudioCoursesApiResponse()); + axiosMock.onGet(courseApiLink).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); const archivedTab = screen.getByText(tabMessages.archivedTabTitle.defaultMessage); diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/__snapshots__/index.test.jsx.snap b/src/studio-home/tabs-section/courses-tab/courses-filters/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..17d0a896e9 --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/__snapshots__/index.test.jsx.snap @@ -0,0 +1,106 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CoursesFilters snapshot 1`] = ` +
+
+
+
+ +
+
+ + +
+
+`; diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/courses-filter-menu/__snapshots__/index.test.jsx.snap b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-filter-menu/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..1e4a3c3778 --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-filter-menu/__snapshots__/index.test.jsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CoursesFilterMenu snapshot 1`] = ` +
+ +
+`; diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/courses-filter-menu/index.jsx b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-filter-menu/index.jsx new file mode 100644 index 0000000000..9843b65330 --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-filter-menu/index.jsx @@ -0,0 +1,75 @@ +import { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { Icon, Dropdown } from '@openedx/paragon'; +import { Check } from '@openedx/paragon/icons'; +import { getStudioHomeCoursesParams } from '../../../../data/selectors'; + +const CoursesFilterMenu = ({ + id: idProp, + menuItems, + onItemMenuSelected, + defaultItemSelectedText, +}) => { + const [itemMenuSelected, setItemMenuSelected] = useState(defaultItemSelectedText); + const { cleanFilters } = useSelector(getStudioHomeCoursesParams); + const handleCourseTypeSelected = (name, value) => { + setItemMenuSelected(name); + onItemMenuSelected(value); + }; + + const courseTypeSelectedIcon = (itemValue) => (itemValue === itemMenuSelected ? ( + + ) : null); + + useEffect(() => { + if (cleanFilters) { + setItemMenuSelected(defaultItemSelectedText); + } + }, [cleanFilters]); + + return ( + + + {itemMenuSelected} + + + {menuItems.map(({ id, name, value }) => ( + handleCourseTypeSelected(name, value)} + data-testid={`item-menu-${id}`} + > + {name} {courseTypeSelectedIcon(name)} + + ))} + + + ); +}; + +CoursesFilterMenu.defaultProps = { + defaultItemSelectedText: '', + menuItems: [], +}; + +CoursesFilterMenu.propTypes = { + onItemMenuSelected: PropTypes.func.isRequired, + defaultItemSelectedText: PropTypes.string, + id: PropTypes.string.isRequired, + menuItems: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }), + ), +}; + +export default CoursesFilterMenu; diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/courses-filter-menu/index.test.jsx b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-filter-menu/index.test.jsx new file mode 100644 index 0000000000..a66f24e22e --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-filter-menu/index.test.jsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { screen, fireEvent, render } from '@testing-library/react'; + +import CoursesFilterMenu from '.'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +describe('CoursesFilterMenu', () => { + const onCourseTypeSelectedMock = jest.fn(); + + const menuItemsMock = [ + { + id: 'active-courses', + name: 'Active', + value: 'active-courses', + }, + { + id: 'upcoming-courses', + name: 'Upcoming', + value: 'upcoming-courses', + }, + { + id: 'archived-courses', + name: 'Archived', + value: 'archived-courses', + }, + ]; + + const renderComponent = (overrideProps = {}) => render( + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + useSelector.mockReturnValue({ + currentPage: 1, + order: 'display_name', + search: '', + activeOnly: false, + archivedOnly: false, + cleanFilters: false, + }); + }); + + it('snapshot', () => { + const { container } = renderComponent(); + expect(container).toMatchSnapshot(); + }); + + it('should render without crashing', () => { + renderComponent(); + const courseFilterMenuToggle = screen.getByTestId('course-filter-menu-toggle'); + expect(courseFilterMenuToggle).toBeInTheDocument(); + }); + + it('should show the items when the menu is clicked', () => { + renderComponent(); + const courseFilterMenuToggle = screen.getByTestId('course-filter-menu-toggle'); + expect(courseFilterMenuToggle).toBeInTheDocument(); + fireEvent.click(courseFilterMenuToggle); + const activeCoursesMenuItem = screen.getByText('Active'); + const upcomingCoursesMenuItem = screen.getByText('Upcoming'); + const archiveCoursesMenuItem = screen.getByText('Archived'); + expect(activeCoursesMenuItem).toBeInTheDocument(); + expect(upcomingCoursesMenuItem).toBeInTheDocument(); + expect(archiveCoursesMenuItem).toBeInTheDocument(); + }); + + it('should show an icon when a menu item is selected', () => { + renderComponent(); + const courseFilterMenuToggle = screen.getByTestId('course-filter-menu-toggle'); + expect(courseFilterMenuToggle).toBeInTheDocument(); + fireEvent.click(courseFilterMenuToggle); + const activeCoursesMenuItem = screen.getByTestId('item-menu-active-courses'); + fireEvent.click(activeCoursesMenuItem); + fireEvent.click(courseFilterMenuToggle); + expect(screen.getByTestId('menu-item-icon')).toBeInTheDocument(); + }); + + it('should call onCourseTypeSelected function when a menu item is selected ', () => { + renderComponent(); + const courseFilterMenuToggle = screen.getByTestId('course-filter-menu-toggle'); + expect(courseFilterMenuToggle).toBeInTheDocument(); + fireEvent.click(courseFilterMenuToggle); + const activeCoursesMenuItem = screen.getByTestId('item-menu-active-courses'); + fireEvent.click(activeCoursesMenuItem); + expect(onCourseTypeSelectedMock).toHaveBeenCalled(); + }); +}); diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/__snapshots__/index.test.jsx.snap b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..ef18a8a53b --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/__snapshots__/index.test.jsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CoursesTypesFilterMenu snapshot 1`] = ` +
+ +
+`; diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/index.jsx b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/index.jsx new file mode 100644 index 0000000000..837d11dca9 --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/index.jsx @@ -0,0 +1,56 @@ +import { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +import CoursesFilterMenu from '../courses-filter-menu'; + +const CoursesOrderFilterMenu = ({ onItemMenuSelected }) => { + const intl = useIntl(); + + const courseOrders = useMemo( + () => [ + { + id: 'az-courses', + name: intl.formatMessage(messages.coursesOrderFilterMenuAscendantCurses), + value: 'azCourses', + }, + { + id: 'za-courses', + name: intl.formatMessage(messages.coursesOrderFilterMenuDescendantCurses), + value: 'zaCourses', + }, + { + id: 'newest-courses', + name: intl.formatMessage(messages.coursesOrderFilterMenuNewestCurses), + value: 'newestCourses', + }, + { + id: 'oldest-courses', + name: intl.formatMessage(messages.coursesOrderFilterMenuOldestCurses), + value: 'oldestCourses', + }, + ], + [intl], + ); + + const handleCourseTypeSelected = (courseOrder) => { + onItemMenuSelected(courseOrder); + }; + + return ( + + ); +}; + +CoursesOrderFilterMenu.propTypes = { + onItemMenuSelected: PropTypes.func.isRequired, +}; + +export default CoursesOrderFilterMenu; diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/index.test.jsx b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/index.test.jsx new file mode 100644 index 0000000000..dc00320a5b --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/index.test.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { screen, fireEvent, render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import CoursesOrderFilterMenu from '.'; +import message from './messages'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +describe('CoursesTypesFilterMenu', () => { + // eslint-disable-next-line react/prop-types + const IntlProviderWrapper = ({ children }) => ( + + {children} + + ); + + const onItemMenuSelectedMock = jest.fn(); + + const renderComponent = (overrideProps = {}) => render( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + useSelector.mockReturnValue({ + currentPage: 1, + order: 'display_name', + search: '', + activeOnly: false, + archivedOnly: false, + cleanFilters: false, + }); + }); + + it('snapshot', () => { + const { container } = renderComponent(); + expect(container).toMatchSnapshot(); + }); + + it('should render without crashing', () => { + renderComponent(); + const courseOrderMenu = screen.getByTestId('dropdown-toggle-courses-order-menu'); + expect(courseOrderMenu).toBeInTheDocument(); + }); + + it('should show the items when the menu is clicked', () => { + renderComponent(); + const courseOrderMenuFilter = screen.getByTestId('dropdown-toggle-courses-order-menu'); + fireEvent.click(courseOrderMenuFilter); + const { defaultMessage: ascendantCoursesMenuText } = message.coursesOrderFilterMenuAscendantCurses; + const { defaultMessage: descendantCoursesMenuText } = message.coursesOrderFilterMenuDescendantCurses; + const { defaultMessage: newWestCoursesMenuText } = message.coursesOrderFilterMenuNewestCurses; + const { defaultMessage: oldestCoursesMenuText } = message.coursesOrderFilterMenuOldestCurses; + const ascendantCoursesMenuItem = screen.getByTestId('item-menu-az-courses'); + const descendantCoursesMenuItem = screen.getByText(descendantCoursesMenuText); + const newestCoursesMenuItem = screen.getByText(newWestCoursesMenuText); + const oldestCoursesMenuItem = screen.getByText(oldestCoursesMenuText); + expect(ascendantCoursesMenuItem.textContent).toContain(ascendantCoursesMenuText); + expect(descendantCoursesMenuItem).toBeInTheDocument(); + expect(newestCoursesMenuItem).toBeInTheDocument(); + expect(oldestCoursesMenuItem).toBeInTheDocument(); + }); + + it('should show an icon when a menu item is selected ', () => { + renderComponent(); + const courseOrderMenu = screen.getByTestId('dropdown-toggle-courses-order-menu'); + fireEvent.click(courseOrderMenu); + const ascendantCoursesMenuItem = screen.getByTestId('item-menu-az-courses'); + fireEvent.click(ascendantCoursesMenuItem); + fireEvent.click(courseOrderMenu); + expect(screen.getByTestId('menu-item-icon')).toBeInTheDocument(); + }); + + it('should call onCourseTypeSelected function when a menu item is selected ', () => { + renderComponent(); + const courseOrderMenu = screen.getByTestId('dropdown-toggle-courses-order-menu'); + fireEvent.click(courseOrderMenu); + const ascendantCoursesMenuItem = screen.getByTestId('item-menu-az-courses'); + fireEvent.click(ascendantCoursesMenuItem); + expect(onItemMenuSelectedMock).toHaveBeenCalled(); + }); +}); diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/messages.js b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/messages.js new file mode 100644 index 0000000000..4021312b4a --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-order-filter-menu/messages.js @@ -0,0 +1,22 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + coursesOrderFilterMenuAscendantCurses: { + id: 'course-authoring.studio-home.courses.tab.order-filter-menu.ascendant-courses', + defaultMessage: 'Name A-Z', + }, + coursesOrderFilterMenuDescendantCurses: { + id: 'course-authoring.studio-home.courses.tab.order-filter-menu.descendant-courses', + defaultMessage: 'Name Z-A', + }, + coursesOrderFilterMenuNewestCurses: { + id: 'course-authoring.studio-home.courses.tab.order-filter-menu.newest-courses', + defaultMessage: 'Newest', + }, + coursesOrderFilterMenuOldestCurses: { + id: 'course-authoring.studio-home.courses.tab.order-filter-menu.oldest-courses', + defaultMessage: 'Oldest', + }, +}); + +export default messages; diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/__snapshots__/index.test.jsx.snap b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..12ba90344f --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/__snapshots__/index.test.jsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CoursesTypesFilterMenu snapshot 1`] = ` +
+ +
+`; diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/index.jsx b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/index.jsx new file mode 100644 index 0000000000..70189e8fed --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/index.jsx @@ -0,0 +1,51 @@ +import { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +import CoursesFilterMenu from '../courses-filter-menu'; + +const CoursesTypesFilterMenu = ({ onItemMenuSelected }) => { + const intl = useIntl(); + + const courseTypes = useMemo( + () => [ + { + id: 'all-courses', + name: intl.formatMessage(messages.coursesTypesFilterMenuAllCurses), + value: 'allCourses', + }, + { + id: 'active-courses', + name: intl.formatMessage(messages.coursesTypesFilterMenuActiveCurses), + value: 'activeCourses', + }, + { + id: 'archived-courses', + name: intl.formatMessage(messages.coursesTypesFilterMenuArchivedCurses), + value: 'archivedCourses', + }, + ], + [intl], + ); + + const handleCourseTypeSelected = (courseType) => { + onItemMenuSelected(courseType); + }; + + return ( + + ); +}; + +CoursesTypesFilterMenu.propTypes = { + onItemMenuSelected: PropTypes.func.isRequired, +}; + +export default CoursesTypesFilterMenu; diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/index.test.jsx b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/index.test.jsx new file mode 100644 index 0000000000..ddd2fd9746 --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/index.test.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { screen, fireEvent, render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import CoursesTypesFilterMenu from '.'; +import message from './messages'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +describe('CoursesTypesFilterMenu', () => { + // eslint-disable-next-line react/prop-types + const IntlProviderWrapper = ({ children }) => ( + + {children} + + ); + + const onItemMenuSelectedMock = jest.fn(); + + const renderComponent = (overrideProps = {}) => render( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + useSelector.mockReturnValue({ + currentPage: 1, + order: 'display_name', + search: '', + activeOnly: false, + archivedOnly: false, + cleanFilters: false, + }); + }); + + it('snapshot', () => { + const { container } = renderComponent(); + expect(container).toMatchSnapshot(); + }); + + it('should render without crashing', () => { + renderComponent(); + const courseTypesMenu = screen.getByTestId('dropdown-toggle-course-type-menu'); + expect(courseTypesMenu).toBeInTheDocument(); + }); + + it('should show the items when the menu is clicked', () => { + renderComponent(); + const courseTypeMenuFilter = screen.getByTestId('dropdown-toggle-course-type-menu'); + fireEvent.click(courseTypeMenuFilter); + const { defaultMessage: activeCoursesMenuText } = message.coursesTypesFilterMenuActiveCurses; + const { defaultMessage: allCoursesMenuText } = message.coursesTypesFilterMenuAllCurses; + const { defaultMessage: archiveCoursesMenuText } = message.coursesTypesFilterMenuArchivedCurses; + const activeCoursesMenuItem = screen.getByText(activeCoursesMenuText); + const allCoursesMenuItem = screen.getByTestId('item-menu-all-courses'); + const archiveCoursesMenuItem = screen.getByText(archiveCoursesMenuText); + expect(activeCoursesMenuItem).toBeInTheDocument(); + expect(allCoursesMenuItem.textContent).toContain(allCoursesMenuText); + expect(archiveCoursesMenuItem).toBeInTheDocument(); + }); + + it('should show an icon when a menu item is selected ', () => { + renderComponent(); + const courseTypesMenu = screen.getByTestId('dropdown-toggle-course-type-menu'); + fireEvent.click(courseTypesMenu); + const activeCoursesMenuItem = screen.getByTestId('item-menu-active-courses'); + fireEvent.click(activeCoursesMenuItem); + fireEvent.click(courseTypesMenu); + expect(screen.getByTestId('menu-item-icon')).toBeInTheDocument(); + }); + + it('should call onCourseTypeSelected function when a menu item is selected ', () => { + renderComponent(); + const courseTypesMenu = screen.getByTestId('dropdown-toggle-course-type-menu'); + fireEvent.click(courseTypesMenu); + const activeCoursesMenuItem = screen.getByTestId('item-menu-active-courses'); + fireEvent.click(activeCoursesMenuItem); + expect(onItemMenuSelectedMock).toHaveBeenCalled(); + }); +}); diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/messages.js b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/messages.js new file mode 100644 index 0000000000..f75aad8b08 --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/courses-types-filter-menu/messages.js @@ -0,0 +1,18 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + coursesTypesFilterMenuAllCurses: { + id: 'course-authoring.studio-home.courses.tab.types-filter-menu.all-courses', + defaultMessage: 'All courses', + }, + coursesTypesFilterMenuActiveCurses: { + id: 'course-authoring.studio-home.courses.tab.types-filter-menu.active-courses', + defaultMessage: 'Active', + }, + coursesTypesFilterMenuArchivedCurses: { + id: 'course-authoring.studio-home.courses.tab.types-filter-menu.archived-courses', + defaultMessage: 'Archived', + }, +}); + +export default messages; diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/index.jsx b/src/studio-home/tabs-section/courses-tab/courses-filters/index.jsx new file mode 100644 index 0000000000..961b15c301 --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/index.jsx @@ -0,0 +1,138 @@ +import { useState, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import { SearchField } from '@openedx/paragon'; +import { debounce } from 'lodash'; + +import { getStudioHomeCoursesParams } from '../../../data/selectors'; +import { updateStudioHomeCoursesCustomParams } from '../../../data/slice'; +import { fetchStudioHomeData } from '../../../data/thunks'; +import { LoadingSpinner } from '../../../../generic/Loading'; +import CoursesTypesFilterMenu from './courses-types-filter-menu'; +import CoursesOrderFilterMenu from './courses-order-filter-menu'; +import './index.scss'; + +/* regex to check if a string has only whitespace + example " " +*/ +const regexOnlyWhiteSpaces = /^\s+$/; + +const CoursesFilters = ({ + dispatch, + locationValue, + onSubmitSearchField, + isLoading, +}) => { + const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams); + const { + order, + search, + activeOnly, + archivedOnly, + cleanFilters, + } = studioHomeCoursesParams; + const [inputSearchValue, setInputSearchValue] = useState(''); + + const getFilterTypeData = (baseFilters) => ({ + archivedCourses: { ...baseFilters, archivedOnly: true, activeOnly: undefined }, + activeCourses: { ...baseFilters, activeOnly: true, archivedOnly: undefined }, + allCourses: { ...baseFilters, archivedOnly: undefined, activeOnly: undefined }, + azCourses: { ...baseFilters, order: 'display_name' }, + zaCourses: { ...baseFilters, order: '-display_name' }, + newestCourses: { ...baseFilters, order: '-created' }, + oldestCourses: { ...baseFilters, order: 'created' }, + }); + + const handleMenuFilterItemSelected = (filterType) => { + const baseFilters = { + currentPage: 1, + search, + order, + isFiltered: true, + archivedOnly, + activeOnly, + cleanFilters: false, + }; + + const filterParams = getFilterTypeData(baseFilters); + const filterParamsFormat = filterParams[filterType] || baseFilters; + const { + coursesOrderLabel, + coursesTypesLabel, + isFiltered, + orderTypeLabel, + cleanFilters: cleanFilterParams, + currentPage, + ...customParams + } = filterParamsFormat; + dispatch(updateStudioHomeCoursesCustomParams(filterParamsFormat)); + dispatch(fetchStudioHomeData(locationValue, false, { page: 1, ...customParams }, true)); + }; + + const handleSearchCourses = (searchValueDebounced) => { + const valueFormatted = searchValueDebounced.trim(); + const filterParams = { + search: valueFormatted.length > 0 ? valueFormatted : undefined, + activeOnly, + archivedOnly, + order, + }; + const hasOnlySpaces = regexOnlyWhiteSpaces.test(searchValueDebounced); + + if (valueFormatted !== search && !hasOnlySpaces && !cleanFilters) { + dispatch(updateStudioHomeCoursesCustomParams({ + currentPage: 1, + isFiltered: true, + cleanFilters: false, + ...filterParams, + })); + + dispatch(fetchStudioHomeData(locationValue, false, { page: 1, ...filterParams }, true)); + } + + setInputSearchValue(searchValueDebounced); + }; + + const handleSearchCoursesDebounced = useCallback( + debounce((value) => handleSearchCourses(value), 400), + [], + ); + + return ( +
+
+ + {isLoading && ( + + + + )} +
+ + + +
+ ); +}; + +CoursesFilters.defaultProps = { + locationValue: '', + onSubmitSearchField: () => {}, + isLoading: false, +}; + +CoursesFilters.propTypes = { + dispatch: PropTypes.func.isRequired, + locationValue: PropTypes.string, + onSubmitSearchField: PropTypes.func, + isLoading: PropTypes.bool, +}; + +export default CoursesFilters; diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/index.scss b/src/studio-home/tabs-section/courses-tab/courses-filters/index.scss new file mode 100644 index 0000000000..68902f2b44 --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/index.scss @@ -0,0 +1,4 @@ +.search-field-loading { + margin-left: -25%; + margin-top: .5rem; +} diff --git a/src/studio-home/tabs-section/courses-tab/courses-filters/index.test.jsx b/src/studio-home/tabs-section/courses-tab/courses-filters/index.test.jsx new file mode 100644 index 0000000000..02d69e284a --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/courses-filters/index.test.jsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { + screen, fireEvent, render, waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import CoursesFilters from '.'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +describe('CoursesFilters', () => { + const dispatchMock = jest.fn(); + + // eslint-disable-next-line react/prop-types + const IntlProviderWrapper = ({ children }) => ( + + {children} + + ); + + const renderComponent = (overrideProps = {}) => render( + + + , + + ); + + beforeEach(() => { + jest.clearAllMocks(); + useSelector.mockReturnValue({ + currentPage: 1, + order: 'display_name', + search: '', + activeOnly: false, + archivedOnly: false, + cleanFilters: false, + }); + }); + + it('snapshot', () => { + const { container } = renderComponent(); + expect(container).toMatchSnapshot(); + }); + + it('should render without crashing', () => { + renderComponent(); + const searchInput = screen.getByTestId('input-filter-courses-search'); + expect(searchInput).toBeInTheDocument(); + }); + + it('should render type courses menu and order curses menu', () => { + renderComponent(); + const courseTypeMenuFilter = screen.getByTestId('dropdown-toggle-course-type-menu'); + const courseOrderMenuFilter = screen.getByTestId('dropdown-toggle-courses-order-menu'); + expect(courseTypeMenuFilter).toBeInTheDocument(); + expect(courseOrderMenuFilter).toBeInTheDocument(); + }); + + it('should call dispatch when the search input changes', async () => { + renderComponent(); + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'test' } }); + await waitFor(() => expect(dispatchMock).toHaveBeenCalled()); + }); + + it('should call dispatch when a menu item of course type menu is selected', () => { + renderComponent(); + const courseTypeMenuFilter = screen.getByTestId('dropdown-toggle-course-type-menu'); + fireEvent.click(courseTypeMenuFilter); + const activeCoursesMenuItem = screen.getByTestId('item-menu-active-courses'); + fireEvent.click(activeCoursesMenuItem); + expect(dispatchMock).toHaveBeenCalled(); + }); + + it('should call dispatch when a menu item of course order menu is selected', () => { + renderComponent(); + const courseOrderMenuFilter = screen.getByTestId('dropdown-toggle-courses-order-menu'); + fireEvent.click(courseOrderMenuFilter); + const descendantCoursesMenuItem = screen.getByTestId('item-menu-za-courses'); + fireEvent.click(descendantCoursesMenuItem); + expect(dispatchMock).toHaveBeenCalled(); + }); + + it('should clear the search input when cleanFilters is true', () => { + useSelector.mockReturnValue({ + cleanFilters: true, + }); + renderComponent(); + const searchInput = screen.getByRole('searchbox'); + expect(searchInput.value).toBe(''); + }); + + it('should call dispatch with the correct parameters when a menu item of course type menu is selected', () => { + renderComponent(); + const courseTypeMenuFilter = screen.getByTestId('dropdown-toggle-course-type-menu'); + fireEvent.click(courseTypeMenuFilter); + const activeCoursesMenuItem = screen.getByTestId('item-menu-active-courses'); + fireEvent.click(activeCoursesMenuItem); + + // Check that updateStudioHomeCoursesCustomParams is called with the correct payload + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + payload: { + currentPage: 1, + search: '', + order: 'display_name', + isFiltered: true, + archivedOnly: undefined, + activeOnly: true, + cleanFilters: false, + }, + })); + }); + + it('should handle search input submission', () => { + const handleSubmit = jest.fn(); + renderComponent({ onSubmitSearchField: handleSubmit }); + const searchInput = screen.getByRole('searchbox'); + userEvent.type(searchInput, 'testing{enter}'); + expect(handleSubmit).toHaveBeenCalled(); + }); + + it('should call dispatch after debounce delay when the search input changes', async () => { + renderComponent(); + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'test' } }); + await waitFor(() => expect(dispatchMock).toHaveBeenCalled(), { timeout: 500 }); + expect(dispatchMock).toHaveBeenCalledWith(expect.anything()); + }); + + it('should not call dispatch when the search input contains only spaces', async () => { + renderComponent(); + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: ' ' } }); + await waitFor(() => expect(dispatchMock).not.toHaveBeenCalled(), { timeout: 500 }); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it('should display the loading spinner when isLoading is true', () => { + renderComponent({ isLoading: true }); + const spinner = screen.getByTestId('loading-search-spinner'); + expect(spinner).toBeInTheDocument(); + }); + + it('should not display the loading spinner when isLoading is false', () => { + renderComponent({ isLoading: false }); + const spinner = screen.queryByTestId('loading-search-spinner'); + expect(spinner).not.toBeInTheDocument(); + }); + + it('should clear the search input and call dispatch when the reset button is clicked', async () => { + renderComponent(); + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'test' } }); + const form = searchInput.closest('form'); + const resetButton = form.querySelector('button[type="reset"]'); + fireEvent.click(resetButton); + expect(searchInput.value).toBe(''); + }); +}); diff --git a/src/studio-home/tabs-section/courses-tab/index.jsx b/src/studio-home/tabs-section/courses-tab/index.jsx index b76e88702d..3233ef50ec 100644 --- a/src/studio-home/tabs-section/courses-tab/index.jsx +++ b/src/studio-home/tabs-section/courses-tab/index.jsx @@ -3,7 +3,13 @@ import { useLocation } from 'react-router-dom'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Icon, Row, Pagination } from '@openedx/paragon'; +import { + Icon, + Row, + Pagination, + Alert, + Button, +} from '@openedx/paragon'; import { Error } from '@openedx/paragon/icons'; import { COURSE_CREATOR_STATES } from '../../../constants'; @@ -12,12 +18,13 @@ import { updateStudioHomeCoursesCustomParams } from '../../data/slice'; import { fetchStudioHomeData } from '../../data/thunks'; import CardItem from '../../card-item'; import CollapsibleStateWithAction from '../../collapsible-state-with-action'; -import { sortAlphabeticallyArray } from '../utils'; import ContactAdministrator from './contact-administrator'; +import CoursesFilters from './courses-filters'; import ProcessingCourses from '../../processing-courses'; import { LoadingSpinner } from '../../../generic/Loading'; import AlertMessage from '../../../generic/alert-message'; import messages from '../messages'; +import './index.scss'; const CoursesTab = ({ coursesDataItems, @@ -37,21 +44,54 @@ const CoursesTab = ({ courseCreatorStatus, optimizationEnabled, } = useSelector(getStudioHomeData); - const { currentPage } = useSelector(getStudioHomeCoursesParams); + const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams); + const { currentPage, isFiltered } = studioHomeCoursesParams; const hasAbilityToCreateCourse = courseCreatorStatus === COURSE_CREATOR_STATES.granted; const showCollapsible = [ COURSE_CREATOR_STATES.denied, COURSE_CREATOR_STATES.pending, COURSE_CREATOR_STATES.unrequested, ].includes(courseCreatorStatus); + const locationValue = location.search ?? ''; const handlePageSelected = (page) => { - dispatch(fetchStudioHomeData(location.search ?? '', false, { page }, true)); - dispatch(updateStudioHomeCoursesCustomParams({ currentPage: page })); + const { + search, + order, + archivedOnly, + activeOnly, + } = studioHomeCoursesParams; + + const customParams = { + search, + order, + archivedOnly, + activeOnly, + }; + + dispatch(fetchStudioHomeData(locationValue, false, { page, ...customParams }, true)); + dispatch(updateStudioHomeCoursesCustomParams({ currentPage: page, isFiltered: true })); + }; + + const handleCleanFilters = () => { + const customParams = { + currentPage: 1, + search: undefined, + order: 'display_name', + isFiltered: true, + cleanFilters: true, + archivedOnly: undefined, + activeOnly: undefined, + }; + + dispatch(fetchStudioHomeData(locationValue, false, { page: 1, order: 'display_name' }, true)); + dispatch(updateStudioHomeCoursesCustomParams(customParams)); }; + + const isNotFilteringCourses = !isFiltered && !isLoading; const hasCourses = coursesDataItems?.length > 0; - if (isLoading) { + if (isLoading && !isFiltered) { return ( @@ -60,21 +100,22 @@ const CoursesTab = ({ } return ( - isFailed ? ( + isFailed && !isFiltered ? ( - {intl.formatMessage(messages.courseTabErrorMessage)} + {intl.formatMessage(messages.courseTabErrorMessage)} )} /> ) : ( - <> - {isShowProcessing && } - {hasCourses && isEnabledPagination && ( -
+
+ {isShowProcessing && !isEnabledPagination && } + {isEnabledPagination && ( +
+

{intl.formatMessage(messages.coursesPaginationInfo, { length: coursesDataItems.length, @@ -85,7 +126,7 @@ const CoursesTab = ({ )} {hasCourses ? ( <> - {sortAlphabeticallyArray(coursesDataItems).map( + {coursesDataItems.map( ({ courseKey, displayName, @@ -99,6 +140,7 @@ const CoursesTab = ({ }) => ( )} - ) : (!optimizationEnabled && ( + ) : (!optimizationEnabled && isNotFilteringCourses && ( ) )} + + {isFiltered && !hasCourses && !isLoading && ( + + + {intl.formatMessage(messages.coursesTabCourseNotFoundAlertTitle)} + +

+ {intl.formatMessage(messages.coursesTabCourseNotFoundAlertMessage)} +

+ + + )} {showCollapsible && ( )} - +
) ); }; diff --git a/src/studio-home/tabs-section/courses-tab/index.scss b/src/studio-home/tabs-section/courses-tab/index.scss new file mode 100644 index 0000000000..da6d5f7411 --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/index.scss @@ -0,0 +1,3 @@ +.courses-tab-container { + min-height: 80vh; +} diff --git a/src/studio-home/tabs-section/courses-tab/index.test.jsx b/src/studio-home/tabs-section/courses-tab/index.test.jsx new file mode 100644 index 0000000000..3c4811edc0 --- /dev/null +++ b/src/studio-home/tabs-section/courses-tab/index.test.jsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; + +import initializeStore from '../../../store'; +import { studioHomeMock } from '../../__mocks__'; +import { initialState } from '../../factories/mockApiResponses'; + +import CoursesTab from '.'; + +const mockDispatch = jest.fn(); + +const onClickNewCourse = jest.fn(); +const isShowProcessing = false; +const isLoading = false; +const isFailed = false; +const numPages = 1; +const coursesCount = studioHomeMock.courses.length; +const isEnabledPagination = true; +const showNewCourseContainer = true; + +const renderComponent = (overrideProps = {}, studioHomeState = {}) => { + // Generate a custom initial state based on studioHomeCoursesRequestParams + const customInitialState = { + ...initialState, + studioHome: { + ...initialState.studioHome, + ...studioHomeState, + }, + }; + + // Initialize the store with the custom initial state + const store = initializeStore(customInitialState); + + return render( + + + + + , + ); +}; + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + }); + + it('should render correctly', async () => { + renderComponent(); + const coursesPaginationInfo = screen.getByTestId('pagination-info'); + const coursesTypesMenu = screen.getByTestId('dropdown-toggle-course-type-menu'); + const coursesOrderMenu = screen.getByTestId('dropdown-toggle-courses-order-menu'); + const coursesFilterSearchInput = screen.getByTestId('input-filter-courses-search'); + expect(coursesPaginationInfo).toBeInTheDocument(); + expect(coursesTypesMenu).toBeInTheDocument(); + expect(coursesOrderMenu).toBeInTheDocument(); + expect(coursesFilterSearchInput).toBeInTheDocument(); + }); + + it('should not render pagination and filter elements when isEnabledPagination is false', () => { + renderComponent({ isEnabledPagination: false }); + const coursesPaginationInfo = screen.queryByTestId('pagination-info'); + const coursesTypesMenu = screen.queryByTestId('dropdown-toggle-course-type-menu'); + const coursesOrderMenu = screen.queryByTestId('dropdown-toggle-courses-order-menu'); + const coursesFilterSearchInput = screen.queryByTestId('input-filter-courses-search'); + expect(coursesPaginationInfo).not.toBeInTheDocument(); + expect(coursesTypesMenu).not.toBeInTheDocument(); + expect(coursesOrderMenu).not.toBeInTheDocument(); + expect(coursesFilterSearchInput).not.toBeInTheDocument(); + }); + + it('should render loading spinner when isLoading is true and isFiltered is false', () => { + const props = { isLoading: true, coursesDataItems: [] }; + const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } }; + renderComponent(props, customStoreData); + const loadingSpinner = screen.getByRole('status'); + expect(loadingSpinner).toBeInTheDocument(); + }); + + it('should render an error message when something went wrong', () => { + const props = { isFailed: true }; + const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: false } }; + renderComponent(props, customStoreData); + const alertErrorFailed = screen.queryByTestId('error-failed-message'); + expect(alertErrorFailed).toBeInTheDocument(); + }); + + it('should render an alert message when there is not courses found', () => { + const props = { isLoading: false, coursesDataItems: [] }; + const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } }; + renderComponent(props, customStoreData); + const alertCoursesNotFound = screen.queryByTestId('courses-not-found-alert'); + expect(alertCoursesNotFound).toBeInTheDocument(); + }); + + it('should render processing courses component when isEnabledPagination is false and isShowProcessing is true', () => { + const props = { isShowProcessing: true, isEnabledPagination: false }; + const customStoreData = { + studioHomeData: { + inProcessCourseActions: [], + }, + studioHomeCoursesRequestParams: { + currentPage: 1, + isFiltered: true, + }, + }; + renderComponent(props, customStoreData); + const alertCoursesNotFound = screen.queryByTestId('processing-courses-title'); + expect(alertCoursesNotFound).toBeInTheDocument(); + }); +}); diff --git a/src/studio-home/tabs-section/messages.js b/src/studio-home/tabs-section/messages.js index 40c3d95c70..5ae2e139b2 100644 --- a/src/studio-home/tabs-section/messages.js +++ b/src/studio-home/tabs-section/messages.js @@ -29,6 +29,18 @@ const messages = defineMessages({ id: 'course-authoring.studio-home.archived.tab.error.message', defaultMessage: 'Failed to fetch archived courses. Please try again later.', }, + coursesTabCourseNotFoundAlertTitle: { + id: 'course-authoring.studio-home.courses.tab.course.not.found.alert.title', + defaultMessage: 'We could not find any result', + }, + coursesTabCourseNotFoundAlertMessage: { + id: 'course-authoring.studio-home.courses.tab.course.not.found.alert.message', + defaultMessage: 'There are no courses with the current filters.', + }, + coursesTabCourseNotFoundAlertCleanFiltersButton: { + id: 'course-authoring.studio-home.courses.tab.course.not.found.alert.clean.filters.button', + defaultMessage: 'Clear filters', + }, taxonomiesTabTitle: { id: 'course-authoring.studio-home.taxonomies.tab.title', defaultMessage: 'Taxonomies',