feat: home studio search filters (#846)

* feat: pagination studio home for courses

* chore: addressing some comments

* refactor: addressing pr comments

* test: adding test for studio home slice

* feat: search input and filters for course home

* fix: using open edx paragon

* feat: usedebounce hook for searching courses

* fix: filters params for searching coruses

* feat: adding coursekey when course name is empty

* chore: remove edit in studio button

* fix: message changed when courses were  not found

* refactor: support courses tab filters and pagination

* test: more cases for course filters component

* refactor: coverage for onsubmit search field

* test: unit test for courses tab component

* feat: loading for search input and layout of course tab

* fix: linter problems

* test: adding more tests for courses tab

* refactor: don't ignore empty string as a case for searching

* refactor: manage empty search bar as special case for searching

* fix: remove expected dispatch mock for clear button

---------

Co-authored-by: Maria Grimaldi <maria.grimaldi@edunext.co>
This commit is contained in:
Jhon Vente
2024-04-11 15:25:19 -05:00
committed by GitHub
parent fc3e38f63b
commit 2641aecc8a
31 changed files with 1307 additions and 75 deletions

View File

@@ -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 }) => (
<Spinner
animation="border"
role="status"
variant="primary"
size={size}
screenReaderText={(
<FormattedMessage
id="authoring.loading"
@@ -17,6 +19,14 @@ export const LoadingSpinner = () => (
/>
);
LoadingSpinner.defaultProps = {
size: undefined,
};
LoadingSpinner.propTypes = {
size: PropTypes.string,
};
const Loading = () => (
<div className="d-flex justify-content-center align-items-center flex-column vh-100">
<LoadingSpinner />

View File

@@ -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 (<Loading />);
}
@@ -138,7 +139,7 @@ const StudioHome = ({ intl }) => {
tabsData={studioHomeData}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={() => setShowNewCourseContainer(true)}
isShowProcessing={isShowProcessing}
isShowProcessing={isShowProcessing && !isFiltered}
dispatch={dispatch}
isPaginationCoursesEnabled={isPaginationCoursesEnabled}
/>

View File

@@ -47,7 +47,7 @@ module.exports = {
org: 'org.0',
rerunLink: null,
run: 'Run_0',
url: null,
url: '',
},
],
inProcessCourseActions: [],

View File

@@ -87,4 +87,20 @@ describe('<CardItem />', () => {
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(
<RootWrapper
{...props}
displayName=""
courseKey={courseKeyTest}
lmsLink="lmsLink"
rerunLink="returnLink"
url="url"
/>,
);
expect(getByText(courseKeyTest)).toBeInTheDocument();
});
});

View File

@@ -41,6 +41,7 @@ const CardItem = ({
const isShowRerunLink = allowCourseReruns
&& rerunCreatorStatus
&& courseCreatorStatus === COURSE_CREATOR_STATES.granted;
const hasDisplayName = displayName.trim().length ? displayName : courseKey;
return (
<Card className="card-item">
@@ -51,7 +52,7 @@ const CardItem = ({
className="card-item-title"
destination={courseUrl().toString()}
>
{displayName}
{hasDisplayName}
</Hyperlink>
) : (
<span className="card-item-title">{displayName}</span>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ const ProcessingCourses = () => {
return (
<>
<div className="text-gray-500 small">
<div className="text-gray-500 small" data-testid="processing-courses-title">
{intl.formatMessage(messages.processingTitle)}
</div>
<hr />

View File

@@ -88,8 +88,9 @@ describe('<TabsSection />', () => {
describe('course tab', () => {
it('should render specific course details', async () => {
render(<RootWrapper />);
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('<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());
@@ -187,8 +188,10 @@ describe('<TabsSection />', () => {
describe('archived tab', () => {
it('should switch to Archived tab and render specific archived course details', async () => {
render(<RootWrapper />);
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);

View File

@@ -0,0 +1,106 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoursesFilters snapshot 1`] = `
<div>
<div
class="d-flex"
>
<div
class="d-flex flex-row"
>
<div
class="pgn__searchfield d-flex mr-4"
data-testid="input-filter-courses-search"
>
<form
class="pgn__searchfield-form"
role="search"
>
<label
class="m-0"
for="pgn-searchfield-input-1"
>
<span
class="sr-only"
>
search
</span>
</label>
<input
class="form-control"
id="pgn-searchfield-input-1"
name="searchfield-input"
placeholder="Search"
role="searchbox"
type="text"
value=""
/>
<button
class="btn"
type="submit"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5Zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14Z"
fill="currentColor"
/>
</svg>
</span>
<span
class="sr-only"
>
submit search
</span>
</button>
</form>
</div>
</div>
<div
class="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
id="dropdown-toggle-dropdown-toggle-course-type-menu"
>
<button
alt="dropdown-toggle-menu-items"
aria-expanded="false"
aria-haspopup="true"
class="dropdown-toggle-menu-items dropdown-toggle btn btn-none"
data-testid="dropdown-toggle-course-type-menu"
id="dropdown-toggle-course-type-menu"
type="button"
>
All courses
</button>
</div>
<div
class="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
id="dropdown-toggle-dropdown-toggle-courses-order-menu"
>
<button
alt="dropdown-toggle-menu-items"
aria-expanded="false"
aria-haspopup="true"
class="dropdown-toggle-menu-items dropdown-toggle btn btn-none"
data-testid="dropdown-toggle-courses-order-menu"
id="dropdown-toggle-courses-order-menu"
type="button"
>
Name A-Z
</button>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoursesFilterMenu snapshot 1`] = `
<div>
<div
class="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
id="dropdown-toggle-course-filter-menu-toggle"
>
<button
alt="dropdown-toggle-menu-items"
aria-expanded="false"
aria-haspopup="true"
class="dropdown-toggle-menu-items dropdown-toggle btn btn-none"
data-testid="course-filter-menu-toggle"
id="course-filter-menu-toggle"
type="button"
>
</button>
</div>
</div>
`;

View File

@@ -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 ? (
<Icon src={Check} className="ml-2" data-testid="menu-item-icon" />
) : null);
useEffect(() => {
if (cleanFilters) {
setItemMenuSelected(defaultItemSelectedText);
}
}, [cleanFilters]);
return (
<Dropdown id={`dropdown-toggle-${idProp}`}>
<Dropdown.Toggle
alt="dropdown-toggle-menu-items"
id={idProp}
variant="none"
className="dropdown-toggle-menu-items"
data-testid={idProp}
>
{itemMenuSelected}
</Dropdown.Toggle>
<Dropdown.Menu>
{menuItems.map(({ id, name, value }) => (
<Dropdown.Item
key={id}
onClick={() => handleCourseTypeSelected(name, value)}
data-testid={`item-menu-${id}`}
>
{name} {courseTypeSelectedIcon(name)}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
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;

View File

@@ -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(
<CoursesFilterMenu
menuItems={menuItemsMock}
onItemMenuSelected={onCourseTypeSelectedMock}
id="course-filter-menu-toggle"
{...overrideProps}
/>,
);
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();
});
});

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoursesTypesFilterMenu snapshot 1`] = `
<div>
<div
class="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
id="dropdown-toggle-dropdown-toggle-courses-order-menu"
>
<button
alt="dropdown-toggle-menu-items"
aria-expanded="false"
aria-haspopup="true"
class="dropdown-toggle-menu-items dropdown-toggle btn btn-none"
data-testid="dropdown-toggle-courses-order-menu"
id="dropdown-toggle-courses-order-menu"
type="button"
>
Name A-Z
</button>
</div>
</div>
`;

View File

@@ -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 (
<CoursesFilterMenu
id="dropdown-toggle-courses-order-menu"
menuItems={courseOrders}
onItemMenuSelected={handleCourseTypeSelected}
defaultItemSelectedText={intl.formatMessage(messages.coursesOrderFilterMenuAscendantCurses)}
/>
);
};
CoursesOrderFilterMenu.propTypes = {
onItemMenuSelected: PropTypes.func.isRequired,
};
export default CoursesOrderFilterMenu;

View File

@@ -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 }) => (
<IntlProvider locale="en" messages={{}}>
{children}
</IntlProvider>
);
const onItemMenuSelectedMock = jest.fn();
const renderComponent = (overrideProps = {}) => render(
<IntlProviderWrapper>
<CoursesOrderFilterMenu
onItemMenuSelected={onItemMenuSelectedMock}
{...overrideProps}
/>
</IntlProviderWrapper>,
);
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();
});
});

View File

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

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoursesTypesFilterMenu snapshot 1`] = `
<div>
<div
class="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
id="dropdown-toggle-dropdown-toggle-course-type-menu"
>
<button
alt="dropdown-toggle-menu-items"
aria-expanded="false"
aria-haspopup="true"
class="dropdown-toggle-menu-items dropdown-toggle btn btn-none"
data-testid="dropdown-toggle-course-type-menu"
id="dropdown-toggle-course-type-menu"
type="button"
>
All courses
</button>
</div>
</div>
`;

View File

@@ -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 (
<CoursesFilterMenu
id="dropdown-toggle-course-type-menu"
menuItems={courseTypes}
onItemMenuSelected={handleCourseTypeSelected}
defaultItemSelectedText={intl.formatMessage(messages.coursesTypesFilterMenuAllCurses)}
/>
);
};
CoursesTypesFilterMenu.propTypes = {
onItemMenuSelected: PropTypes.func.isRequired,
};
export default CoursesTypesFilterMenu;

View File

@@ -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 }) => (
<IntlProvider locale="en" messages={{}}>
{children}
</IntlProvider>
);
const onItemMenuSelectedMock = jest.fn();
const renderComponent = (overrideProps = {}) => render(
<IntlProviderWrapper>
<CoursesTypesFilterMenu
onItemMenuSelected={onItemMenuSelectedMock}
{...overrideProps}
/>
</IntlProviderWrapper>,
);
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();
});
});

View File

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

View File

@@ -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 (
<div className="d-flex">
<div className="d-flex flex-row">
<SearchField
onSubmit={onSubmitSearchField}
onChange={handleSearchCoursesDebounced}
value={cleanFilters ? '' : inputSearchValue}
className="mr-4"
data-testid="input-filter-courses-search"
placeholder="Search"
/>
{isLoading && (
<span className="search-field-loading" data-testid="loading-search-spinner">
<LoadingSpinner size="sm" />
</span>
)}
</div>
<CoursesTypesFilterMenu onItemMenuSelected={handleMenuFilterItemSelected} />
<CoursesOrderFilterMenu onItemMenuSelected={handleMenuFilterItemSelected} />
</div>
);
};
CoursesFilters.defaultProps = {
locationValue: '',
onSubmitSearchField: () => {},
isLoading: false,
};
CoursesFilters.propTypes = {
dispatch: PropTypes.func.isRequired,
locationValue: PropTypes.string,
onSubmitSearchField: PropTypes.func,
isLoading: PropTypes.bool,
};
export default CoursesFilters;

View File

@@ -0,0 +1,4 @@
.search-field-loading {
margin-left: -25%;
margin-top: .5rem;
}

View File

@@ -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 }) => (
<IntlProvider locale="en" messages={{}}>
{children}
</IntlProvider>
);
const renderComponent = (overrideProps = {}) => render(
<IntlProviderWrapper>
<CoursesFilters
dispatch={dispatchMock}
{...overrideProps}
/>
</IntlProviderWrapper>,
);
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('');
});
});

View File

@@ -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 (
<Row className="m-0 mt-4 justify-content-center">
<LoadingSpinner />
@@ -60,21 +100,22 @@ const CoursesTab = ({
}
return (
isFailed ? (
isFailed && !isFiltered ? (
<AlertMessage
variant="danger"
description={(
<Row className="m-0 align-items-center">
<Icon src={Error} className="text-danger-500 mr-1" />
<span>{intl.formatMessage(messages.courseTabErrorMessage)}</span>
<span data-testid="error-failed-message">{intl.formatMessage(messages.courseTabErrorMessage)}</span>
</Row>
)}
/>
) : (
<>
{isShowProcessing && <ProcessingCourses />}
{hasCourses && isEnabledPagination && (
<div className="d-flex justify-content-end">
<div className="courses-tab-container">
{isShowProcessing && !isEnabledPagination && <ProcessingCourses />}
{isEnabledPagination && (
<div className="d-flex flex-row justify-content-between my-4">
<CoursesFilters dispatch={dispatch} locationValue={locationValue} isLoading={isLoading} />
<p data-testid="pagination-info">
{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 = ({
}) => (
<CardItem
key={courseKey}
courseKey={courseKey}
displayName={displayName}
lmsLink={lmsLink}
rerunLink={rerunLink}
@@ -122,7 +164,7 @@ const CoursesTab = ({
/>
)}
</>
) : (!optimizationEnabled && (
) : (!optimizationEnabled && isNotFilteringCourses && (
<ContactAdministrator
hasAbilityToCreateCourse={hasAbilityToCreateCourse}
showNewCourseContainer={showNewCourseContainer}
@@ -130,13 +172,27 @@ const CoursesTab = ({
/>
)
)}
{isFiltered && !hasCourses && !isLoading && (
<Alert className="mt-4">
<Alert.Heading>
{intl.formatMessage(messages.coursesTabCourseNotFoundAlertTitle)}
</Alert.Heading>
<p data-testid="courses-not-found-alert">
{intl.formatMessage(messages.coursesTabCourseNotFoundAlertMessage)}
</p>
<Button variant="primary" onClick={handleCleanFilters}>
{intl.formatMessage(messages.coursesTabCourseNotFoundAlertCleanFiltersButton)}
</Button>
</Alert>
)}
{showCollapsible && (
<CollapsibleStateWithAction
state={courseCreatorStatus}
className="mt-3"
/>
)}
</>
</div>
)
);
};

View File

@@ -0,0 +1,3 @@
.courses-tab-container {
min-height: 80vh;
}

View File

@@ -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(
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<CoursesTab
dispatch={mockDispatch}
coursesDataItems={studioHomeMock.courses}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={onClickNewCourse}
isShowProcessing={isShowProcessing}
isLoading={isLoading}
isFailed={isFailed}
numPages={numPages}
coursesCount={coursesCount}
isEnabledPagination={isEnabledPagination}
{...overrideProps}
/>
</IntlProvider>
</AppProvider>,
);
};
describe('<CoursesTab />', () => {
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();
});
});

View File

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