diff --git a/src/App.test.tsx b/src/App.test.tsx
index e0276695..163db0f1 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -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);
diff --git a/src/catalog/CatalogPage.test.tsx b/src/catalog/CatalogPage.test.tsx
index 48687a23..648c2da0 100644
--- a/src/catalog/CatalogPage.test.tsx
+++ b/src/catalog/CatalogPage.test.tsx
@@ -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';
@@ -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 }) => (
+
{message}
+ ),
+}));
+
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({
@@ -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();
+
+ 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,
@@ -35,13 +74,28 @@ describe('CatalogPage', () => {
});
render();
- 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();
+
+ 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,
@@ -49,13 +103,92 @@ describe('CatalogPage', () => {
});
render();
- 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: 'support@example.com',
+ ENABLE_COURSE_DISCOVERY: false,
+ });
+
+ mockUseCourseListSearch.mockReturnValue({
+ isLoading: false,
+ isError: false,
+ data: mockCourseListSearchResponse,
+ });
+
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+ 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`);
});
});
});
diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx
index af956892..b562c98c 100644
--- a/src/catalog/CatalogPage.tsx
+++ b/src/catalog/CatalogPage.tsx
@@ -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();
@@ -20,6 +22,7 @@ const CatalogPage = () => {
isLoading,
isError,
} = useCourseListSearch();
+ const isMedium = useMediaQuery({ maxWidth: breakpoints.large.maxWidth });
if (isLoading) {
return (
@@ -44,40 +47,67 @@ const CatalogPage = () => {
const totalCourses = courseData?.results?.length ?? 0;
return (
-
-
+
-
-
- {totalCourses === 0 ? (
-
- ) : (
-
- {courseData?.results?.map(course => (
-
- ))}
-
- )}
-
-
- {totalCourses > 0 && (
-
- )}
-
-
+ {totalCourses > 0 ? (
+ <>
+
+
+
+
+
+
+
+ >
+ ) : (
+
+ )}
);
};
diff --git a/src/catalog/messages.ts b/src/catalog/messages.ts
index a7b4cb74..0a76492c 100644
--- a/src/catalog/messages.ts
+++ b/src/catalog/messages.ts
@@ -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.',
},
});
diff --git a/src/catalog/types.ts b/src/catalog/types.ts
new file mode 100644
index 00000000..53eb098b
--- /dev/null
+++ b/src/catalog/types.ts
@@ -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;
+}
diff --git a/src/catalog/utils.ts b/src/catalog/utils.ts
new file mode 100644
index 00000000..a3cab3ce
--- /dev/null
+++ b/src/catalog/utils.ts
@@ -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,
+}));
diff --git a/src/data/course-list-search/types.ts b/src/data/course-list-search/types.ts
index 9e334fd1..efb48135 100644
--- a/src/data/course-list-search/types.ts
+++ b/src/data/course-list-search/types.ts
@@ -1,8 +1,10 @@
export interface CourseListSearchResponse {
- count: number;
+ took: number;
total: number;
results: {
id: string;
+ index: string;
+ type: string;
title: string;
data: {
id: string;
@@ -10,6 +12,7 @@ export interface CourseListSearchResponse {
start: string;
imageUrl: string;
org: string;
+ orgImageUrl?: string;
content: {
displayName: string;
overview?: string;
@@ -21,4 +24,14 @@ export interface CourseListSearchResponse {
catalogVisibility: string;
};
}[];
+ aggs: {
+ [key: string]: {
+ terms: {
+ [key: string]: number;
+ };
+ total: number;
+ other: number;
+ };
+ };
+ maxScore: number;
}
diff --git a/src/generic/course-card/CourseCard.test.tsx b/src/generic/course-card/CourseCard.test.tsx
index 217f2dbd..629a49b6 100644
--- a/src/generic/course-card/CourseCard.test.tsx
+++ b/src/generic/course-card/CourseCard.test.tsx
@@ -9,7 +9,7 @@ import messages from './messages';
describe('CourseCard', () => {
const renderComponent = (course = mockCourseResponse) => render(
- ,
+ ,
);
it('renders course information correctly', () => {
@@ -124,7 +124,7 @@ describe('CourseCard', () => {
describe('when isLoading is true', () => {
const renderLoadingComponent = () => render(
- ,
+ ,
);
it('renders skeleton elements when loading', () => {
diff --git a/src/generic/course-card/__tests__/utils.test.ts b/src/generic/course-card/__tests__/utils.test.ts
new file mode 100644
index 00000000..39f3b3b4
--- /dev/null
+++ b/src/generic/course-card/__tests__/utils.test.ts
@@ -0,0 +1,149 @@
+import { getConfig } from '@edx/frontend-platform';
+
+import { mockCourseResponse } from '@src/__mocks__';
+import { DATE_FORMAT_OPTIONS } from '@src/constants';
+import { getFullImageUrl, getStartDateDisplay } from '../utils';
+import type { Course } from '../types';
+
+describe('course-card utils', () => {
+ describe('getFullImageUrl', () => {
+ it('returns empty string when path is undefined', () => {
+ expect(getFullImageUrl(undefined)).toBe('');
+ });
+
+ it('returns empty string when path is null', () => {
+ expect(getFullImageUrl(null as any)).toBe('');
+ });
+
+ it('returns empty string when path is empty string', () => {
+ expect(getFullImageUrl('')).toBe('');
+ });
+
+ it('constructs full URL when path is provided', () => {
+ const imagePath = mockCourseResponse.data.imageUrl;
+ const expectedUrl = `${getConfig().LMS_BASE_URL}${imagePath}`;
+
+ expect(getFullImageUrl(imagePath)).toBe(expectedUrl);
+ });
+ });
+
+ describe('getStartDateDisplay', () => {
+ const mockIntl = {
+ formatDate: jest.fn(),
+ } as any;
+
+ beforeEach(() => {
+ mockIntl.formatDate.mockClear();
+ });
+
+ it('returns advertisedStart when available', () => {
+ const course: Course = {
+ ...mockCourseResponse,
+ data: {
+ ...mockCourseResponse.data,
+ advertisedStart: 'Spring 2024',
+ },
+ };
+
+ const result = getStartDateDisplay(course, mockIntl);
+
+ expect(result).toBe('Spring 2024');
+ expect(mockIntl.formatDate).not.toHaveBeenCalled();
+ });
+
+ it('returns formatted start date when advertisedStart is not available', () => {
+ const course: Course = {
+ ...mockCourseResponse,
+ data: {
+ ...mockCourseResponse.data,
+ advertisedStart: undefined,
+ start: '2024-04-01T00:00:00Z',
+ },
+ };
+
+ const formattedDate = 'Apr 1, 2024';
+ mockIntl.formatDate.mockReturnValue(formattedDate);
+
+ const result = getStartDateDisplay(course, mockIntl);
+
+ expect(result).toBe(formattedDate);
+ expect(mockIntl.formatDate).toHaveBeenCalledWith(
+ new Date('2024-04-01T00:00:00Z'),
+ DATE_FORMAT_OPTIONS,
+ );
+ });
+
+ it('returns formatted start date when advertisedStart is empty string', () => {
+ const course: Course = {
+ ...mockCourseResponse,
+ data: {
+ ...mockCourseResponse.data,
+ advertisedStart: '',
+ start: '2024-06-15T10:30:00Z',
+ },
+ };
+
+ const formattedDate = 'Jun 15, 2024';
+ mockIntl.formatDate.mockReturnValue(formattedDate);
+
+ const result = getStartDateDisplay(course, mockIntl);
+
+ expect(result).toBe(formattedDate);
+ expect(mockIntl.formatDate).toHaveBeenCalledWith(
+ new Date('2024-06-15T10:30:00Z'),
+ DATE_FORMAT_OPTIONS,
+ );
+ });
+
+ it('returns empty string when both advertisedStart and start are not available', () => {
+ const course: Course = {
+ ...mockCourseResponse,
+ data: {
+ ...mockCourseResponse.data,
+ advertisedStart: undefined,
+ start: '',
+ },
+ };
+
+ const result = getStartDateDisplay(course, mockIntl);
+
+ expect(result).toBe('');
+ expect(mockIntl.formatDate).not.toHaveBeenCalled();
+ });
+
+ it('returns empty string when course is undefined', () => {
+ const result = getStartDateDisplay(undefined as unknown as Course, mockIntl);
+
+ expect(result).toBe('');
+ expect(mockIntl.formatDate).not.toHaveBeenCalled();
+ });
+
+ it('returns empty string when course.data is undefined', () => {
+ const course = {
+ ...mockCourseResponse,
+ data: undefined,
+ } as any;
+
+ const result = getStartDateDisplay(course, mockIntl);
+
+ expect(result).toBe('');
+ expect(mockIntl.formatDate).not.toHaveBeenCalled();
+ });
+
+ it('prioritizes advertisedStart over start date', () => {
+ const course: Course = {
+ ...mockCourseResponse,
+ data: {
+ ...mockCourseResponse.data,
+ advertisedStart: 'Fall 2024',
+ start: '2024-09-01T00:00:00Z',
+ },
+ };
+
+ const result = getStartDateDisplay(course, mockIntl);
+
+ expect(result).toBe('Fall 2024');
+ expect(mockIntl.formatDate).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/generic/course-card/index.tsx b/src/generic/course-card/index.tsx
index fbfb4832..d4778b1e 100644
--- a/src/generic/course-card/index.tsx
+++ b/src/generic/course-card/index.tsx
@@ -12,16 +12,16 @@ import { getFullImageUrl, getStartDateDisplay } from './utils';
// TODO: Determine the final design for the course Card component.
// Issue: https://github.com/openedx/frontend-app-catalog/issues/10
-export const CourseCard = ({ course, isLoading }: CourseCardProps) => {
+export const CourseCard = ({ original: courseData, isLoading }: CourseCardProps) => {
const intl = useIntl();
const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.small.maxWidth });
- const startDateDisplay = course ? getStartDateDisplay(course, intl) : null;
+ const startDateDisplay = courseData?.data?.start ? getStartDateDisplay(courseData, intl) : null;
return (
{
data-testid="course-card"
>
- {course?.data.number}
- {course?.data.org}
+ {courseData?.data.number}
+ {courseData?.data.org}
>
)}
size="sm"
diff --git a/src/generic/course-card/types.ts b/src/generic/course-card/types.ts
index 8625e571..d9898e4c 100644
--- a/src/generic/course-card/types.ts
+++ b/src/generic/course-card/types.ts
@@ -27,6 +27,6 @@ export interface Course {
}
export interface CourseCardProps {
- course?: Course;
+ original?: Course;
isLoading?: boolean;
}
diff --git a/src/generic/sub-header/SubHeader.test.tsx b/src/generic/sub-header/SubHeader.test.tsx
index 7762b8d5..3fc883ac 100644
--- a/src/generic/sub-header/SubHeader.test.tsx
+++ b/src/generic/sub-header/SubHeader.test.tsx
@@ -20,4 +20,18 @@ describe('SubHeader', () => {
expect(header).toHaveClass('mb-5', 'd-flex', 'justify-content-between');
expect(screen.getByRole('heading', { level: 1 })).toHaveClass('mb-0');
});
+
+ it('applies custom className when provided', () => {
+ const customClass = 'custom-header-class';
+ render();
+ const header = screen.getByRole('banner');
+ expect(header).toHaveClass(customClass);
+ });
+
+ it('merges custom className with default classes', () => {
+ const customClass = 'my-custom-class';
+ render();
+ const header = screen.getByRole('banner');
+ expect(header).toHaveClass('mb-5', 'd-flex', 'justify-content-between', customClass);
+ });
});
diff --git a/src/generic/sub-header/index.tsx b/src/generic/sub-header/index.tsx
index e8286c65..c2b06569 100644
--- a/src/generic/sub-header/index.tsx
+++ b/src/generic/sub-header/index.tsx
@@ -1,7 +1,9 @@
+import classNames from 'classnames';
+
import { SubHeaderProps } from './types';
-export const SubHeader = ({ title }: SubHeaderProps) => (
-
+export const SubHeader = ({ title, className }: SubHeaderProps) => (
+
);
diff --git a/src/generic/sub-header/types.ts b/src/generic/sub-header/types.ts
index 861c7937..f2807984 100644
--- a/src/generic/sub-header/types.ts
+++ b/src/generic/sub-header/types.ts
@@ -1,3 +1,4 @@
export interface SubHeaderProps {
title: string;
+ className?: string;
}
diff --git a/src/home/components/courses-list/CoursesList.tsx b/src/home/components/courses-list/CoursesList.tsx
index 17cfa3c5..6cda18c8 100644
--- a/src/home/components/courses-list/CoursesList.tsx
+++ b/src/home/components/courses-list/CoursesList.tsx
@@ -89,7 +89,7 @@ const CoursesList = () => {
{courseData?.results?.map(course => (
-
+
))}
{courseData?.total > maxCourses && (
diff --git a/src/plugin-slots/HomeCourseCardSlot/index.tsx b/src/plugin-slots/HomeCourseCardSlot/index.tsx
index e38d922d..d17a1abe 100644
--- a/src/plugin-slots/HomeCourseCardSlot/index.tsx
+++ b/src/plugin-slots/HomeCourseCardSlot/index.tsx
@@ -5,7 +5,7 @@ import { CourseCardProps } from '@src/generic/course-card/types';
// TODO: Resolve the issue with the pluginProps.
// https://github.com/openedx/frontend-app-catalog/pull/18#pullrequestreview-3212047271
-const HomeCourseCardSlot = ({ course, isLoading }: CourseCardProps) => (
+const HomeCourseCardSlot = ({ original: courseData, isLoading }: CourseCardProps) => (
(
}}
pluginProps={{ isLoading }}
>
-
+
);