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) => ( +

{title}

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