Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,7 @@ describe('App', () => {
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
});

expect(
screen.getByText(
messages.totalCoursesHeading.defaultMessage.replace(
'{totalCourses}',
mockCourseListSearchResponse.results.length,
),
),
).toBeInTheDocument();
expect(screen.getByText(messages.exploreCourses.defaultMessage)).toBeInTheDocument();

const courseCards = screen.getAllByRole('link');
expect(courseCards.length).toBe(mockCourseListSearchResponse.results.length);
Expand Down
153 changes: 143 additions & 10 deletions src/catalog/CatalogPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { render, within, screen } from '../setupTest';
import { getConfig } from '@edx/frontend-platform';

import {
render, within, screen, waitFor,
} from '../setupTest';
import { useCourseListSearch } from '../data/course-list-search/hooks';
import { mockCourseListSearchResponse } from '../__mocks__';
import CatalogPage from './CatalogPage';
Expand All @@ -8,10 +12,27 @@ jest.mock('../data/course-list-search/hooks', () => ({
useCourseListSearch: jest.fn(),
}));

jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));

jest.mock('@edx/frontend-platform/react', () => ({
ErrorPage: ({ message }: { message: string }) => (
<div data-testid="error-page">{message}</div>
),
}));

const mockUseCourseListSearch = useCourseListSearch as jest.Mock;
const mockGetConfig = getConfig as jest.Mock;

describe('CatalogPage', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => {
jest.clearAllMocks();
mockGetConfig.mockReturnValue({
INFO_EMAIL: process.env.INFO_EMAIL,
ENABLE_COURSE_DISCOVERY: process.env.ENABLE_COURSE_DISCOVERY,
});
});

it('should show loading state', () => {
mockUseCourseListSearch.mockReturnValue({
Expand All @@ -24,6 +45,24 @@ describe('CatalogPage', () => {
expect(screen.getByRole('status')).toBeInTheDocument();
});

it('should show error state', () => {
mockUseCourseListSearch.mockReturnValue({
isLoading: false,
isError: true,
data: null,
});

render(<CatalogPage />);

const alert = screen.getByRole('alert');
expect(alert).toHaveClass('alert-danger');

const errorPage = screen.getByTestId('error-page');
expect(errorPage).toHaveTextContent(
messages.errorMessage.defaultMessage.replace('{supportEmail}', getConfig().INFO_EMAIL),
);
});

it('should show empty courses state', () => {
mockUseCourseListSearch.mockReturnValue({
isLoading: false,
Expand All @@ -35,27 +74,121 @@ describe('CatalogPage', () => {
});

render(<CatalogPage />);
expect(screen.getByText(messages.totalCoursesHeading.defaultMessage.replace('{totalCourses}', 0))).toBeInTheDocument();
expect(screen.getByText(messages.exploreCourses.defaultMessage)).toBeInTheDocument();
const infoAlert = screen.getByRole('alert');
expect(within(infoAlert).getByText(messages.noCoursesAvailable.defaultMessage)).toBeInTheDocument();
expect(within(infoAlert).getByText(messages.noCoursesAvailableMessage.defaultMessage)).toBeInTheDocument();
});

it('should display courses when data is available', () => {
it('should render DataTable with correct configuration', () => {
mockUseCourseListSearch.mockReturnValue({
isLoading: false,
isError: false,
data: mockCourseListSearchResponse,
});

render(<CatalogPage />);

expect(screen.getByText('Language')).toBeInTheDocument();
expect(screen.getByText('English')).toBeInTheDocument();
expect(screen.getByText('Ukrainian')).toBeInTheDocument();
expect(screen.getByText('Spanish')).toBeInTheDocument();
});

it('should render DataTable with filters when course discovery is enabled', () => {
mockUseCourseListSearch.mockReturnValue({
isLoading: false,
isError: false,
data: mockCourseListSearchResponse,
});

render(<CatalogPage />);
expect(screen.getByText(
messages.totalCoursesHeading.defaultMessage.replace('{totalCourses}', mockCourseListSearchResponse.results.length),
)).toBeInTheDocument();

// Verify all courses are displayed
mockCourseListSearchResponse.results.forEach(course => {
expect(screen.getByText(course.data.content.displayName)).toBeInTheDocument();
expect(screen.getByText('Language')).toBeInTheDocument();
expect(screen.getByText('Filters')).toBeInTheDocument();
expect(screen.getByText(messages.exploreCourses.defaultMessage)).toBeInTheDocument();
const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage);
expect(searchField).toBeInTheDocument();
});

it('should render DataTable without filters when course discovery is disabled', () => {
mockGetConfig.mockReturnValue({
INFO_EMAIL: '[email protected]',
ENABLE_COURSE_DISCOVERY: false,
});

mockUseCourseListSearch.mockReturnValue({
isLoading: false,
isError: false,
data: mockCourseListSearchResponse,
});

render(<CatalogPage />);

expect(screen.queryByText('Language')).not.toBeInTheDocument();
expect(screen.queryByText('Filters')).not.toBeInTheDocument();
expect(screen.getByText(messages.exploreCourses.defaultMessage)).toBeInTheDocument();
const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage);
expect(searchField).toBeInTheDocument();
});

it('should handle search field interactions', () => {
mockUseCourseListSearch.mockReturnValue({
isLoading: false,
isError: false,
data: mockCourseListSearchResponse,
});

render(<CatalogPage />);

const searchField = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage);

expect(searchField).toHaveValue('');
expect(searchField).toBeInTheDocument();
});

it('should render DataTable row statuses with correct pagination info', async () => {
mockUseCourseListSearch.mockReturnValue({
isLoading: false,
isError: false,
data: mockCourseListSearchResponse,
});

render(<CatalogPage />);

await waitFor(() => {
const rowStatuses = screen.getAllByTestId('row-status');

rowStatuses.forEach(rowStatus => {
expect(rowStatus).toHaveTextContent(
`Showing 1 - ${mockCourseListSearchResponse.results.length} of ${mockCourseListSearchResponse.total}.`,
);
});

expect(rowStatuses.length).toBe(2);
});
});

it('should render course cards with correct content', () => {
mockUseCourseListSearch.mockReturnValue({
isLoading: false,
isError: false,
data: mockCourseListSearchResponse,
});

render(<CatalogPage />);
expect(screen.getByText(messages.exploreCourses.defaultMessage)).toBeInTheDocument();

const courseCards = screen.getAllByTestId('course-card');
expect(courseCards.length).toBe(mockCourseListSearchResponse.results.length);

mockCourseListSearchResponse.results.forEach((course, index) => {
const courseCard = courseCards[index];

expect(within(courseCard).getByText(course.data.content.displayName)).toBeInTheDocument();
expect(within(courseCard).getByText(course.data.content.number)).toBeInTheDocument();
expect(within(courseCard).getByText(course.data.org)).toBeInTheDocument();
expect(courseCard).toHaveAttribute('href', `/courses/${course.data.course}/about`);
});
});
});
102 changes: 66 additions & 36 deletions src/catalog/CatalogPage.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import {
CardGrid, Container, Layout, Alert,
DataTable, Container, SearchField, Alert, breakpoints,
useMediaQuery, TextFilter, CheckboxFilter, CardView,
} from '@openedx/paragon';
import { ErrorPage } from '@edx/frontend-platform/react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';

import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_INDEX } from '@src/data/course-list-search/constants';
import {
AlertNotification, CourseCard, Loading, SubHeader,
} from '../generic';
import { useCourseListSearch } from '../data/course-list-search/hooks';
import messages from './messages';

const GRID_LAYOUT = { xl: [{ span: 9 }, { span: 3 }] };
import { transformResultsForTable } from './utils';

const CatalogPage = () => {
const intl = useIntl();
Expand All @@ -20,6 +22,7 @@ const CatalogPage = () => {
isLoading,
isError,
} = useCourseListSearch();
const isMedium = useMediaQuery({ maxWidth: breakpoints.large.maxWidth });

if (isLoading) {
return (
Expand All @@ -44,40 +47,67 @@ const CatalogPage = () => {
const totalCourses = courseData?.results?.length ?? 0;

return (
<Container className="container-xl pt-5.5">
<SubHeader title={intl.formatMessage(messages.totalCoursesHeading, {
totalCourses,
})}
<Container className="container-xl pt-5.5 mb-6">
<SubHeader
title={intl.formatMessage(messages.exploreCourses)}
Copy link
Contributor Author

@PKulkoRaccoonGang PKulkoRaccoonGang Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform]: Changing the title according to search parameters (legacy functionality) will be added in PRs.

className={classNames({ 'mx-2.5': isMedium })}
/>
<Layout {...GRID_LAYOUT}>
<Layout.Element>
{totalCourses === 0 ? (
<AlertNotification
title={intl.formatMessage(messages.noCoursesAvailable)}
message={intl.formatMessage(messages.noCoursesAvailableMessage)}
/>
) : (
<CardGrid
hasEqualColumnHeights
className="mb-6"
>
{courseData?.results?.map(course => (
<CourseCard
key={course.id}
course={course}
/>
))}
</CardGrid>
)}
</Layout.Element>
<Layout.Element>
{totalCourses > 0 && (
<aside className="sidebar-wrapper">
{/* TODO: Implement sidebar functionality with filters and additional course information */}
</aside>
)}
</Layout.Element>
</Layout>
{totalCourses > 0 ? (
<>
<SearchField
key="search-field"
className={classNames({
'w-auto mx-2.5 mb-0': isMedium,
'mb-4 w-25': !isMedium,
})}
placeholder={intl.formatMessage(messages.searchPlaceholder)}
/>
<DataTable
isLoading={isLoading}
showFiltersInSidebar={!isMedium}
isFilterable={getConfig().ENABLE_COURSE_DISCOVERY}
isSortable
isPaginated
defaultColumnValues={{ Filter: TextFilter }}
itemCount={totalCourses}
initialState={{ pageSize: DEFAULT_PAGE_SIZE, pageIndex: DEFAULT_PAGE_INDEX }}
data={transformResultsForTable(courseData!.results)}
columns={[
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform]: The filtering logic for the DataTable will be added in upcoming PRs. For now, we’re focusing solely on building the UI.

{
Header: 'Language',
accessor: 'language',
Filter: CheckboxFilter,
filter: 'includesValue',
filterChoices: [{
name: 'English',
number: 2,
value: 'English',
},
{
name: 'Ukrainian',
number: 2,
value: 'Ukrainian',
},
{
name: 'Spanish',
number: 1,
value: 'Spanish',
}],
},
]}
>
<DataTable.TableControlBar />
<CardView CardComponent={CourseCard} />
<DataTable.EmptyTable content={intl.formatMessage(messages.noResultsFound)} />
<DataTable.TableFooter />
</DataTable>
</>
) : (
<AlertNotification
title={intl.formatMessage(messages.noCoursesAvailable)}
message={intl.formatMessage(messages.noCoursesAvailableMessage)}
/>
)}
</Container>
);
};
Expand Down
18 changes: 14 additions & 4 deletions src/catalog/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,20 @@ const messages = defineMessages({
defaultMessage: 'There are currently no courses available in the catalog. Please check back later for new offerings.',
description: 'No courses available alert message.',
},
totalCoursesHeading: {
id: 'category.catalog.total-courses-heading',
defaultMessage: 'Viewing {totalCourses} courses',
description: 'Total courses heading.',
searchPlaceholder: {
id: 'category.catalog.search-placeholder',
defaultMessage: 'Search for a course',
description: 'Search placeholder.',
},
exploreCourses: {
id: 'category.catalog.explore-courses',
defaultMessage: 'Explore courses',
description: 'Explore courses.',
},
noResultsFound: {
id: 'category.catalog.no-results-found',
defaultMessage: 'No results found',
description: 'No results found.',
},
});

Expand Down
12 changes: 12 additions & 0 deletions src/catalog/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { CourseListSearchResponse } from '@src/data/course-list-search/types';

export interface TransformedCourseItem {
id: string;
famous_for: string;
language: string;
modes: string[];
org: string;
data: CourseListSearchResponse['results'][0]['data'];
index?: string;
type?: string;
}
17 changes: 17 additions & 0 deletions src/catalog/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { CourseListSearchResponse } from '@src/data/course-list-search/types';

import type { TransformedCourseItem } from './types';

/**
* Transforms course discovery results into a format suitable for DataTable display.
*/
export const transformResultsForTable = (results: CourseListSearchResponse['results']): TransformedCourseItem[] => results.map(item => ({
id: item.id,
famous_for: item.data.content.displayName,
language: item.data.language,
modes: item.data.modes,
org: item.data.org,
data: item.data,
index: item.index,
type: item.type,
}));
Loading