diff --git a/.env.test b/.env.test
index 02bc3ea5..f2bcc2a2 100644
--- a/.env.test
+++ b/.env.test
@@ -23,3 +23,4 @@ PARAGON_THEME_URLS={}
HOMEPAGE_PROMO_VIDEO_YOUTUBE_ID='test-youtube-id'
ENABLE_COURSE_DISCOVERY=true
ENABLE_PROGRAMS=true
+INFO_EMAIL=support@example.com
diff --git a/src/App.test.tsx b/src/App.test.tsx
index d8b03c2f..45e26310 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -1,6 +1,6 @@
-import { mockCourseDiscoveryResponse } from './catalog/__mocks__';
+import { mockCourseListSearchResponse } from './__mocks__';
import messages from './catalog/messages';
-import { useCourseDiscovery } from './catalog/data/hooks';
+import { useCourseListSearch } from './data/course-list-search/hooks';
import {
render, within, waitFor, screen,
} from './setupTest';
@@ -16,11 +16,11 @@ jest.mock('@edx/frontend-platform', () => ({
})),
}));
-jest.mock('./catalog/data/hooks', () => ({
- useCourseDiscovery: jest.fn(),
+jest.mock('./data/course-list-search/hooks', () => ({
+ useCourseListSearch: jest.fn(),
}));
-const mockCourseDiscovery = useCourseDiscovery as jest.Mock;
+const mockCourseListSearch = useCourseListSearch as jest.Mock;
jest.mock('@edx/frontend-platform/react', () => ({
AppProvider: ({ children }: { children: React.ReactNode }) =>
{children}
,
@@ -39,8 +39,8 @@ describe('App', () => {
document.body.innerHTML = '';
});
- mockCourseDiscovery.mockReturnValue({
- data: mockCourseDiscoveryResponse,
+ mockCourseListSearch.mockReturnValue({
+ data: mockCourseListSearchResponse,
isLoading: false,
isError: false,
});
@@ -70,16 +70,16 @@ describe('App', () => {
screen.getByText(
messages.totalCoursesHeading.defaultMessage.replace(
'{totalCourses}',
- mockCourseDiscoveryResponse.results.length,
+ mockCourseListSearchResponse.results.length,
),
),
).toBeInTheDocument();
const courseCards = screen.getAllByRole('link');
- expect(courseCards.length).toBe(mockCourseDiscoveryResponse.results.length);
+ expect(courseCards.length).toBe(mockCourseListSearchResponse.results.length);
courseCards.forEach((card, index) => {
- const course = mockCourseDiscoveryResponse.results[index];
+ const course = mockCourseListSearchResponse.results[index];
const cardContent = within(card);
expect(card).toHaveAttribute('href', `/courses/${course.id}/about`);
diff --git a/src/__mocks__/course.ts b/src/__mocks__/course.ts
index 07ee71de..bd1e2b0f 100644
--- a/src/__mocks__/course.ts
+++ b/src/__mocks__/course.ts
@@ -1,4 +1,6 @@
-export const mockCourseResponse = {
+import { Course } from '@src/generic/course-card/types';
+
+export const mockCourseResponse: Course = {
id: 'course-v1:edX+DemoX+Demo_Course',
data: {
id: 'course-v1:edX+DemoX+Demo_Course',
@@ -6,7 +8,8 @@ export const mockCourseResponse = {
start: '2024-04-01T00:00:00Z',
imageUrl: '/asset-v1:edX+DemoX+Demo_Course+type@asset+block@course_image.jpg',
org: 'edX',
- orgImg: '/asset-v1:edX+DemoX+Demo_Course+type@asset+block@org_image.jpg',
+ orgImageUrl: '/asset-v1:edX+DemoX+Demo_Course+type@asset+block@org_image.jpg',
+ advertisedStart: 'Winter 2025',
content: {
displayName: 'Demonstration Course',
overview: 'Course overview',
diff --git a/src/catalog/__mocks__/courseDiscovery.ts b/src/__mocks__/courseListSearch.ts
similarity index 91%
rename from src/catalog/__mocks__/courseDiscovery.ts
rename to src/__mocks__/courseListSearch.ts
index e812460e..548396c8 100644
--- a/src/catalog/__mocks__/courseDiscovery.ts
+++ b/src/__mocks__/courseListSearch.ts
@@ -1,4 +1,4 @@
-export const mockCourseDiscoveryResponse = {
+export const mockCourseListSearchResponse = {
took: 1,
total: 3,
results: [
@@ -18,6 +18,8 @@ export const mockCourseDiscoveryResponse = {
start: '2030-01-01T00:00:00',
number: '123',
org: 'OpenEdx',
+ orgImageUrl: '/asset-v1:OpenEdx+123+2023+type@asset+block@org_image.jpg',
+ advertisedStart: 'Winter 2025',
modes: [
'audit',
],
@@ -41,6 +43,8 @@ export const mockCourseDiscoveryResponse = {
start: '2030-01-01T00:00:00',
number: '312',
org: 'OpenEdx',
+ orgImageUrl: '/asset-v1:OpenEdx+312+2024+type@asset+block@org_image.jpg',
+ advertisedStart: 'Winter 2025',
modes: [
'audit',
],
@@ -64,6 +68,8 @@ export const mockCourseDiscoveryResponse = {
start: '2030-01-01T00:00:00',
number: '654',
org: 'dev',
+ orgImageUrl: '/asset-v1:dev+654+2024+type@asset+block@org_image.jpg',
+ advertisedStart: 'Winter 2025',
modes: [
'audit',
],
diff --git a/src/__mocks__/index.ts b/src/__mocks__/index.ts
index 4bdfec82..819b062a 100644
--- a/src/__mocks__/index.ts
+++ b/src/__mocks__/index.ts
@@ -1 +1,2 @@
export { mockCourseResponse } from './course';
+export { mockCourseListSearchResponse } from './courseListSearch';
diff --git a/src/assets/images/no-org-image.svg b/src/assets/images/no-org-image.svg
deleted file mode 100644
index 139c11f9..00000000
--- a/src/assets/images/no-org-image.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
diff --git a/src/catalog/CatalogPage.test.tsx b/src/catalog/CatalogPage.test.tsx
index 5f7d9b9a..48687a23 100644
--- a/src/catalog/CatalogPage.test.tsx
+++ b/src/catalog/CatalogPage.test.tsx
@@ -1,20 +1,20 @@
import { render, within, screen } from '../setupTest';
-import { useCourseDiscovery } from './data/hooks';
-import { mockCourseDiscoveryResponse } from './__mocks__';
+import { useCourseListSearch } from '../data/course-list-search/hooks';
+import { mockCourseListSearchResponse } from '../__mocks__';
import CatalogPage from './CatalogPage';
import messages from './messages';
-jest.mock('./data/hooks', () => ({
- useCourseDiscovery: jest.fn(),
+jest.mock('../data/course-list-search/hooks', () => ({
+ useCourseListSearch: jest.fn(),
}));
-const mockUseCourseDiscovery = useCourseDiscovery as jest.Mock;
+const mockUseCourseListSearch = useCourseListSearch as jest.Mock;
describe('CatalogPage', () => {
beforeEach(() => jest.clearAllMocks());
it('should show loading state', () => {
- mockUseCourseDiscovery.mockReturnValue({
+ mockUseCourseListSearch.mockReturnValue({
isLoading: true,
isError: false,
data: null,
@@ -25,11 +25,11 @@ describe('CatalogPage', () => {
});
it('should show empty courses state', () => {
- mockUseCourseDiscovery.mockReturnValue({
+ mockUseCourseListSearch.mockReturnValue({
isLoading: false,
isError: false,
data: {
- ...mockCourseDiscoveryResponse,
+ ...mockCourseListSearchResponse,
results: [],
},
});
@@ -42,19 +42,19 @@ describe('CatalogPage', () => {
});
it('should display courses when data is available', () => {
- mockUseCourseDiscovery.mockReturnValue({
+ mockUseCourseListSearch.mockReturnValue({
isLoading: false,
isError: false,
- data: mockCourseDiscoveryResponse,
+ data: mockCourseListSearchResponse,
});
render();
expect(screen.getByText(
- messages.totalCoursesHeading.defaultMessage.replace('{totalCourses}', mockCourseDiscoveryResponse.results.length),
+ messages.totalCoursesHeading.defaultMessage.replace('{totalCourses}', mockCourseListSearchResponse.results.length),
)).toBeInTheDocument();
// Verify all courses are displayed
- mockCourseDiscoveryResponse.results.forEach(course => {
+ mockCourseListSearchResponse.results.forEach(course => {
expect(screen.getByText(course.data.content.displayName)).toBeInTheDocument();
});
});
diff --git a/src/catalog/CatalogPage.tsx b/src/catalog/CatalogPage.tsx
index 972cf8b1..af956892 100644
--- a/src/catalog/CatalogPage.tsx
+++ b/src/catalog/CatalogPage.tsx
@@ -8,7 +8,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import {
AlertNotification, CourseCard, Loading, SubHeader,
} from '../generic';
-import { useCourseDiscovery } from './data/hooks';
+import { useCourseListSearch } from '../data/course-list-search/hooks';
import messages from './messages';
const GRID_LAYOUT = { xl: [{ span: 9 }, { span: 3 }] };
@@ -19,7 +19,7 @@ const CatalogPage = () => {
data: courseData,
isLoading,
isError,
- } = useCourseDiscovery();
+ } = useCourseListSearch();
if (isLoading) {
return (
diff --git a/src/catalog/__mocks__/index.ts b/src/catalog/__mocks__/index.ts
deleted file mode 100644
index 79a82ff7..00000000
--- a/src/catalog/__mocks__/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { mockCourseDiscoveryResponse } from './courseDiscovery';
diff --git a/src/catalog/data/api.ts b/src/catalog/data/api.ts
deleted file mode 100644
index 8ac59c85..00000000
--- a/src/catalog/data/api.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { camelCaseObject } from '@edx/frontend-platform';
-import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
-
-import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_INDEX } from './constants';
-import { getCourseDiscoveryUrl } from './urls';
-
-import { CourseDiscoveryResponse } from './types';
-
-/**
- * Fetches course discovery data from the API.
- * @async
- */
-export const fetchCourseDiscovery = async (
- pageSize = DEFAULT_PAGE_SIZE,
- pageIndex = DEFAULT_PAGE_INDEX,
-): Promise => {
- const { data } = await getAuthenticatedHttpClient()
- .post(getCourseDiscoveryUrl(), {
- page_size: pageSize,
- page_index: pageIndex,
- });
-
- return camelCaseObject(data);
-};
diff --git a/src/catalog/data/hooks.ts b/src/catalog/data/hooks.ts
deleted file mode 100644
index 1e0bea8a..00000000
--- a/src/catalog/data/hooks.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-
-import { fetchCourseDiscovery } from './api';
-import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_INDEX } from './constants';
-import { CourseDiscoveryResponse } from './types';
-
-/**
- * A React Query hook that fetches course discovery data.
- */
-export const useCourseDiscovery = () => useQuery({
- queryKey: ['courseDiscovery'],
- // Temporary hardcoded values. Will be replaced with dynamic configuration
- // during course Catalog DataTable implementation.
- queryFn: () => fetchCourseDiscovery(DEFAULT_PAGE_SIZE, DEFAULT_PAGE_INDEX),
-});
diff --git a/src/constants.ts b/src/constants.ts
index 0a79e308..7fd49ce9 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -17,3 +17,5 @@ export const DEFAULT_VIDEO_MODAL_HEIGHT = 500;
export const DEFAULT_VIDEO_MODAL_WIDTH = 'auto';
export const DEFAULT_VIDEO_MODAL_SIZE = 'lg';
+
+export const DATE_FORMAT_OPTIONS = { month: 'short', day: 'numeric', year: 'numeric' } as const;
diff --git a/src/catalog/data/__tests__/courseDiscovery.test.tsx b/src/data/course-list-search/__tests__/courseListSearch.test.tsx
similarity index 57%
rename from src/catalog/data/__tests__/courseDiscovery.test.tsx
rename to src/data/course-list-search/__tests__/courseListSearch.test.tsx
index fa8086b6..62d5faaa 100644
--- a/src/catalog/data/__tests__/courseDiscovery.test.tsx
+++ b/src/data/course-list-search/__tests__/courseListSearch.test.tsx
@@ -2,12 +2,11 @@ import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
-import { renderHook, waitFor } from '../../../setupTest';
-import { mockCourseDiscoveryResponse } from '../../__mocks__';
-import { fetchCourseDiscovery } from '../api';
-import { useCourseDiscovery } from '../hooks';
-import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_INDEX } from '../constants';
-import { getCourseDiscoveryUrl } from '../urls';
+import { renderHook, waitFor } from '@src/setupTest';
+import { mockCourseListSearchResponse } from '@src/__mocks__';
+import { fetchCourseListSearch } from '../api';
+import { useCourseListSearch } from '../hooks';
+import { getCourseListSearchUrl } from '../urls';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
@@ -15,36 +14,37 @@ jest.mock('@edx/frontend-platform/auth', () => ({
const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as jest.Mock;
-describe('Course Discovery Data Layer', () => {
- describe('fetchCourseDiscovery', () => {
+const CUSTOM_PAGE_SIZE = 21;
+const CUSTOM_PAGE_INDEX = 2;
+
+describe('Course List Search Data Layer', () => {
+ describe('fetchCourseListSearch', () => {
beforeEach(() => jest.clearAllMocks());
- it('should fetch course discovery data with default parameters', async () => {
- const mockPost = jest.fn().mockResolvedValue({ data: mockCourseDiscoveryResponse });
+ it('should fetch course list search data with default parameters', async () => {
+ const mockPost = jest.fn().mockResolvedValue({ data: mockCourseListSearchResponse });
mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost });
- const result = await fetchCourseDiscovery();
+ const result = await fetchCourseListSearch();
- expect(mockPost).toHaveBeenCalledWith(getCourseDiscoveryUrl(), {
- page_size: DEFAULT_PAGE_SIZE,
- page_index: DEFAULT_PAGE_INDEX,
- });
- expect(result).toEqual(mockCourseDiscoveryResponse);
+ expect(mockPost).toHaveBeenCalledTimes(1);
+ const [url] = mockPost.mock.calls[0];
+ expect(url).toBe(getCourseListSearchUrl());
+ expect(result).toEqual(mockCourseListSearchResponse);
});
- it('should fetch course discovery data with custom parameters', async () => {
- const mockPost = jest.fn().mockResolvedValue({ data: mockCourseDiscoveryResponse });
+ it('should fetch course list search data with custom parameters', async () => {
+ const mockPost = jest.fn().mockResolvedValue({ data: mockCourseListSearchResponse });
mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost });
- const customPageSize = 21;
- const customPageIndex = 2;
+ await fetchCourseListSearch(CUSTOM_PAGE_SIZE, CUSTOM_PAGE_INDEX, true);
- await fetchCourseDiscovery(customPageSize, customPageIndex);
+ const [url, formData] = mockPost.mock.calls[0];
- expect(mockPost).toHaveBeenCalledWith(getCourseDiscoveryUrl(), {
- page_size: customPageSize,
- page_index: customPageIndex,
- });
+ expect(url).toBe(getCourseListSearchUrl());
+ expect((formData as FormData).get('page_size')).toBe(String(CUSTOM_PAGE_SIZE));
+ expect((formData as FormData).get('page_index')).toBe(String(CUSTOM_PAGE_INDEX));
+ expect((formData as FormData).get('enable_course_sorting_by_start_date')).toBe('true');
});
it('should handle API errors', async () => {
@@ -52,11 +52,11 @@ describe('Course Discovery Data Layer', () => {
const mockPost = jest.fn().mockRejectedValue(error);
mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost });
- await expect(fetchCourseDiscovery()).rejects.toThrow('API Error');
+ await expect(fetchCourseListSearch()).rejects.toThrow('API Error');
});
});
- describe('useCourseDiscovery', () => {
+ describe('useCourseListSearch', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -77,26 +77,26 @@ describe('Course Discovery Data Layer', () => {
});
it('should return loading state initially', () => {
- const mockPost = jest.fn().mockResolvedValue({ data: mockCourseDiscoveryResponse });
+ const mockPost = jest.fn().mockResolvedValue({ data: mockCourseListSearchResponse });
mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost });
- const { result } = renderHook(() => useCourseDiscovery(), { wrapper });
+ const { result } = renderHook(() => useCourseListSearch(), { wrapper });
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
});
it('should return data when fetch is successful', async () => {
- const mockPost = jest.fn().mockResolvedValue({ data: mockCourseDiscoveryResponse });
+ const mockPost = jest.fn().mockResolvedValue({ data: mockCourseListSearchResponse });
mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost });
- const { result } = renderHook(() => useCourseDiscovery(), { wrapper });
+ const { result } = renderHook(() => useCourseListSearch(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
- expect(result.current.data).toEqual(mockCourseDiscoveryResponse);
+ expect(result.current.data).toEqual(mockCourseListSearchResponse);
expect(result.current.isError).toBe(false);
});
@@ -105,7 +105,7 @@ describe('Course Discovery Data Layer', () => {
const mockPost = jest.fn().mockRejectedValue(error);
mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost });
- const { result } = renderHook(() => useCourseDiscovery(), { wrapper });
+ const { result } = renderHook(() => useCourseListSearch(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
diff --git a/src/data/course-list-search/api.ts b/src/data/course-list-search/api.ts
new file mode 100644
index 00000000..a5ab70bb
--- /dev/null
+++ b/src/data/course-list-search/api.ts
@@ -0,0 +1,28 @@
+import { camelCaseObject } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_INDEX } from './constants';
+import { getCourseListSearchUrl } from './urls';
+
+import { CourseListSearchResponse } from './types';
+
+/**
+ * Fetches course list search data from the API.
+ * @async
+ */
+export const fetchCourseListSearch = async (
+ pageSize = DEFAULT_PAGE_SIZE,
+ pageIndex = DEFAULT_PAGE_INDEX,
+ enableCourseSortingByStartDate = false,
+): Promise => {
+ const formData = new FormData();
+
+ formData.append('page_size', String(pageSize));
+ formData.append('page_index', String(pageIndex));
+ formData.append('enable_course_sorting_by_start_date', String(enableCourseSortingByStartDate));
+
+ const { data } = await getAuthenticatedHttpClient()
+ .post(getCourseListSearchUrl(), formData);
+
+ return camelCaseObject(data);
+};
diff --git a/src/catalog/data/constants.ts b/src/data/course-list-search/constants.ts
similarity index 100%
rename from src/catalog/data/constants.ts
rename to src/data/course-list-search/constants.ts
diff --git a/src/data/course-list-search/hooks.ts b/src/data/course-list-search/hooks.ts
new file mode 100644
index 00000000..d9190ba2
--- /dev/null
+++ b/src/data/course-list-search/hooks.ts
@@ -0,0 +1,25 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { fetchCourseListSearch } from './api';
+import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_INDEX } from './constants';
+import { CourseListSearchResponse } from './types';
+
+/**
+ * A React Query hook that fetches course list search data.
+ */
+export const useCourseListSearch = ({
+ pageSize,
+ pageIndex,
+ enableCourseSortingByStartDate,
+} = {
+ pageSize: DEFAULT_PAGE_SIZE,
+ pageIndex: DEFAULT_PAGE_INDEX,
+ enableCourseSortingByStartDate: false,
+}) => useQuery({
+ queryKey: ['courseListSearch'],
+ queryFn: () => fetchCourseListSearch(
+ pageSize,
+ pageIndex,
+ enableCourseSortingByStartDate,
+ ),
+});
diff --git a/src/catalog/data/types.ts b/src/data/course-list-search/types.ts
similarity index 86%
rename from src/catalog/data/types.ts
rename to src/data/course-list-search/types.ts
index 90e8be2a..9e334fd1 100644
--- a/src/catalog/data/types.ts
+++ b/src/data/course-list-search/types.ts
@@ -1,5 +1,6 @@
-export interface CourseDiscoveryResponse {
+export interface CourseListSearchResponse {
count: number;
+ total: number;
results: {
id: string;
title: string;
diff --git a/src/catalog/data/urls.ts b/src/data/course-list-search/urls.ts
similarity index 52%
rename from src/catalog/data/urls.ts
rename to src/data/course-list-search/urls.ts
index e0db55d4..52d7f468 100644
--- a/src/catalog/data/urls.ts
+++ b/src/data/course-list-search/urls.ts
@@ -2,4 +2,4 @@ import { getConfig } from '@edx/frontend-platform';
export const getApiBaseUrl = () => getConfig().LMS_BASE_URL;
-export const getCourseDiscoveryUrl = () => `${getApiBaseUrl()}/search/course_discovery/`;
+export const getCourseListSearchUrl = () => `${getApiBaseUrl()}/search/unstable/v0/course_list_search/`;
diff --git a/src/generic/alert-notification/index.tsx b/src/generic/alert-notification/index.tsx
index ca7ae141..b87e0e10 100644
--- a/src/generic/alert-notification/index.tsx
+++ b/src/generic/alert-notification/index.tsx
@@ -4,9 +4,9 @@ import { Info as InfoIcon } from '@openedx/paragon/icons';
import { AlertNotificationProps } from './types';
export const AlertNotification = ({
- variant = 'info', title, message,
+ variant = 'info', title, message, className = '',
}: AlertNotificationProps) => (
-
+
{title}
{message}
diff --git a/src/generic/alert-notification/types.ts b/src/generic/alert-notification/types.ts
index 14bc8f0a..d8520d01 100644
--- a/src/generic/alert-notification/types.ts
+++ b/src/generic/alert-notification/types.ts
@@ -2,4 +2,5 @@ export interface AlertNotificationProps {
variant?: 'info' | 'warning';
title: string;
message: string;
+ className?: string;
}
diff --git a/src/generic/course-card/CourseCard.test.tsx b/src/generic/course-card/CourseCard.test.tsx
index 68cc48b0..217f2dbd 100644
--- a/src/generic/course-card/CourseCard.test.tsx
+++ b/src/generic/course-card/CourseCard.test.tsx
@@ -1,14 +1,15 @@
import { getConfig } from '@edx/frontend-platform';
-import { mockCourseResponse } from '../../__mocks__';
-import { render, screen } from '../../setupTest';
+import { mockCourseResponse } from '@src/__mocks__';
+import { render, screen } from '@src/setupTest';
+import { DATE_FORMAT_OPTIONS } from '@src/constants';
import { CourseCard } from '.';
import messages from './messages';
describe('CourseCard', () => {
const renderComponent = (course = mockCourseResponse) => render(
- ,
+ ,
);
it('renders course information correctly', () => {
@@ -16,21 +17,64 @@ describe('CourseCard', () => {
expect(screen.getByText(mockCourseResponse.data.content.displayName)).toBeInTheDocument();
expect(screen.getByText(mockCourseResponse.data.org)).toBeInTheDocument();
- expect(screen.getByText('Starts: Apr 1, 2024')).toBeInTheDocument();
+ expect(screen.getByText(mockCourseResponse.data.number)).toBeInTheDocument();
});
- it('renders course image with correct src and fallback', () => {
+ it('displays advertisedStart when available', () => {
renderComponent();
- const image = screen.getByAltText(mockCourseResponse.data.content.displayName);
- expect(image).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}${mockCourseResponse.data.imageUrl}`);
+ expect(screen.getByText(
+ messages.startDate.defaultMessage.replace('{startDate}', mockCourseResponse.data.advertisedStart),
+ )).toBeInTheDocument();
+ });
+
+ it('displays formatted start date when advertisedStart is not available', () => {
+ const courseWithoutAdvertisedStart = {
+ ...mockCourseResponse,
+ data: {
+ ...mockCourseResponse.data,
+ advertisedStart: undefined,
+ },
+ };
+
+ renderComponent(courseWithoutAdvertisedStart);
+
+ const expectedDate = new Intl.DateTimeFormat(
+ 'en-US',
+ DATE_FORMAT_OPTIONS,
+ ).format(new Date(courseWithoutAdvertisedStart.data.start));
+
+ expect(screen.getByText(
+ messages.startDate.defaultMessage.replace('{startDate}', expectedDate),
+ )).toBeInTheDocument();
});
- it('renders organization logo with correct src and fallback', () => {
+ it('displays formatted start date when advertisedStart is empty string', () => {
+ const courseWithEmptyAdvertisedStart = {
+ ...mockCourseResponse,
+ data: {
+ ...mockCourseResponse.data,
+ advertisedStart: '',
+ },
+ };
+
+ renderComponent(courseWithEmptyAdvertisedStart);
+
+ const expectedDate = new Intl.DateTimeFormat(
+ 'en-US',
+ DATE_FORMAT_OPTIONS,
+ ).format(new Date(courseWithEmptyAdvertisedStart.data.start));
+
+ expect(screen.getByText(
+ messages.startDate.defaultMessage.replace('{startDate}', expectedDate),
+ )).toBeInTheDocument();
+ });
+
+ it('renders course image with correct src and fallback', () => {
renderComponent();
- const logo = screen.getByAltText(mockCourseResponse.data.org);
- expect(logo).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}${mockCourseResponse.data.orgImg}`);
+ const image = screen.getByAltText(`${mockCourseResponse.data.content.displayName} ${mockCourseResponse.data.number}`);
+ expect(image).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}${mockCourseResponse.data.imageUrl}`);
});
it('formats the link destination correctly', () => {
@@ -46,10 +90,83 @@ describe('CourseCard', () => {
data: {
...mockCourseResponse.data,
start: '',
+ advertisedStart: undefined,
},
};
renderComponent(courseWithoutStart);
- expect(screen.queryByText(messages.startDate.defaultMessage.replace('{startDate}', courseWithoutStart.data.start))).not.toBeInTheDocument();
+ expect(screen.queryByText(/Starts:/)).not.toBeInTheDocument();
+ });
+
+ it('prioritizes advertisedStart over start date', () => {
+ const courseWithBothDates = {
+ ...mockCourseResponse,
+ data: {
+ ...mockCourseResponse.data,
+ start: '2024-04-01T00:00:00Z',
+ advertisedStart: 'Spring 2024',
+ },
+ };
+ renderComponent(courseWithBothDates);
+
+ expect(screen.getByText(
+ messages.startDate.defaultMessage.replace('{startDate}', 'Spring 2024'),
+ )).toBeInTheDocument();
+
+ const formattedStartDate = new Intl.DateTimeFormat(
+ 'en-US',
+ DATE_FORMAT_OPTIONS,
+ ).format(new Date(courseWithBothDates.data.start));
+ expect(screen.queryByText(
+ messages.startDate.defaultMessage.replace('{startDate}', formattedStartDate),
+ )).not.toBeInTheDocument();
+ });
+
+ describe('when isLoading is true', () => {
+ const renderLoadingComponent = () => render(
+ ,
+ );
+
+ it('renders skeleton elements when loading', () => {
+ renderLoadingComponent();
+
+ // Each CourseCard creates 4 skeleton elements (image, header, section, footer)
+ // So 1 card × 4 skeletons = 4 total skeleton elements
+ expect(document.querySelectorAll('.react-loading-skeleton')).toHaveLength(4);
+ });
+
+ it('does not render as a link', () => {
+ renderLoadingComponent();
+
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ });
+
+ it('renders as a div instead of Link', () => {
+ renderLoadingComponent();
+
+ const cardElement = screen.getByTestId('course-card');
+ expect(cardElement).toBeInTheDocument();
+ });
+
+ it('does not display course information when loading', () => {
+ renderLoadingComponent();
+
+ expect(screen.queryByText(mockCourseResponse.data.content.displayName)).not.toBeInTheDocument();
+ expect(screen.queryByText(mockCourseResponse.data.org)).not.toBeInTheDocument();
+ expect(screen.queryByText(mockCourseResponse.data.number)).not.toBeInTheDocument();
+ });
+
+ it('does not display start date when loading', () => {
+ renderLoadingComponent();
+
+ expect(screen.queryByText(messages.startDate.defaultMessage.replace('{startDate}', ''))).not.toBeInTheDocument();
+ });
+
+ it('does not display course image when loading', () => {
+ renderLoadingComponent();
+
+ const imageAlt = `${mockCourseResponse.data.content.displayName} ${mockCourseResponse.data.number}`;
+ expect(screen.queryByAltText(imageAlt)).not.toBeInTheDocument();
+ });
});
});
diff --git a/src/generic/course-card/constants.ts b/src/generic/course-card/constants.ts
deleted file mode 100644
index 12709ec6..00000000
--- a/src/generic/course-card/constants.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const DATE_FORMAT_OPTIONS = { month: 'short', day: 'numeric', year: 'numeric' } as const;
diff --git a/src/generic/course-card/index.tsx b/src/generic/course-card/index.tsx
index 79a50681..fbfb4832 100644
--- a/src/generic/course-card/index.tsx
+++ b/src/generic/course-card/index.tsx
@@ -1,51 +1,54 @@
import { Link } from 'react-router-dom';
-import { Card, useMediaQuery, breakpoints } from '@openedx/paragon';
+import {
+ Card, useMediaQuery, breakpoints, Badge,
+} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import noCourseImg from '@src/assets/images/no-course-image.svg';
-import noOrgImg from '@src/assets/images/no-org-image.svg';
import { CourseCardProps } from './types';
import messages from './messages';
-import { getFullImageUrl } from './utils';
-import { DATE_FORMAT_OPTIONS } from './constants';
+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 }: CourseCardProps) => {
+export const CourseCard = ({ course, isLoading }: CourseCardProps) => {
const intl = useIntl();
const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.small.maxWidth });
- const formattedDate = course?.data?.start
- ? intl.formatDate(new Date(course.data.start), DATE_FORMAT_OPTIONS)
- : '';
+ const startDateDisplay = course ? getStartDateDisplay(course, intl) : null;
return (
-
- {course.data.content.displayName}
- {course.data.org}
- {formattedDate && (
-
- {intl.formatMessage(messages.startDate, {
- startDate: formattedDate,
- })}
-
+
+ {course?.data.number}
+ {course?.data.org}
+ >
)}
-
+ size="sm"
+ />
+
+
);
};
diff --git a/src/generic/course-card/types.ts b/src/generic/course-card/types.ts
index 7383c2ce..8625e571 100644
--- a/src/generic/course-card/types.ts
+++ b/src/generic/course-card/types.ts
@@ -10,7 +10,8 @@ export interface CourseData {
start: string;
imageUrl: string;
org: string;
- orgImg?: string;
+ orgImageUrl?: string;
+ advertisedStart?: string;
content: CourseContent;
number: string;
modes: string[];
@@ -26,5 +27,6 @@ export interface Course {
}
export interface CourseCardProps {
- course: Course;
+ course?: Course;
+ isLoading?: boolean;
}
diff --git a/src/generic/course-card/utils.ts b/src/generic/course-card/utils.ts
index 009ede0a..a9d9c5cf 100644
--- a/src/generic/course-card/utils.ts
+++ b/src/generic/course-card/utils.ts
@@ -1,4 +1,8 @@
import { getConfig } from '@edx/frontend-platform';
+import type { IntlShape } from '@edx/frontend-platform/i18n';
+
+import { DATE_FORMAT_OPTIONS } from '@src/constants';
+import type { Course } from './types';
/**
* Constructs a full URL for an image by combining the LMS base URL with the provided image path.
@@ -9,3 +13,18 @@ export const getFullImageUrl = (path?: string) => {
}
return `${getConfig().LMS_BASE_URL}${path}`;
};
+
+/**
+ * Constructs a start date display for a course by combining the advertised start date and the start date.
+ */
+export const getStartDateDisplay = (course: Course, intl: IntlShape) => {
+ if (course?.data?.advertisedStart) {
+ return course.data.advertisedStart;
+ }
+
+ if (course?.data?.start) {
+ return intl.formatDate(new Date(course.data.start), DATE_FORMAT_OPTIONS);
+ }
+
+ return '';
+};
diff --git a/src/home/HomePage.test.tsx b/src/home/HomePage.test.tsx
index 96150fbf..37e9eab3 100644
--- a/src/home/HomePage.test.tsx
+++ b/src/home/HomePage.test.tsx
@@ -1,10 +1,15 @@
import { getConfig } from '@edx/frontend-platform';
import {
- render, screen, waitFor, userEvent,
+ render, screen, waitFor, userEvent, within,
} from '@src/setupTest';
import genericMessages from '@src/generic/video-modal/messages';
-import { IFRAME_FEATURE_POLICY, DEFAULT_VIDEO_MODAL_HEIGHT } from '../constants';
+import courseCardMessages from '@src/generic/course-card/messages';
+import { useCourseListSearch } from '@src/data/course-list-search/hooks';
+import { mockCourseListSearchResponse } from '@src/__mocks__';
+import {
+ IFRAME_FEATURE_POLICY, DEFAULT_VIDEO_MODAL_HEIGHT, DATE_FORMAT_OPTIONS,
+} from '../constants';
import HomePage from './HomePage';
import messages from './components/home-banner/messages';
@@ -17,7 +22,18 @@ jest.mock('@edx/frontend-platform', () => ({
ensureConfig: jest.fn(),
}));
+jest.mock('@src/data/course-list-search/hooks', () => ({
+ useCourseListSearch: jest.fn(),
+}));
+
+const mockCourseListSearch = useCourseListSearch as jest.Mock;
+
describe('HomePage', () => {
+ mockCourseListSearch.mockReturnValue({
+ data: mockCourseListSearchResponse,
+ isLoading: false,
+ isError: false,
+ });
it('renders without crashing', () => {
render();
@@ -81,4 +97,129 @@ describe('HomePage', () => {
expect(screen.queryByRole('search')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText(messages.searchPlaceholder.defaultMessage)).not.toBeInTheDocument();
});
+
+ describe('CoursesList', () => {
+ it('renders course cards with correct count', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
+ });
+
+ const courseCards = screen.getAllByRole('link');
+ expect(courseCards.length).toBe(mockCourseListSearchResponse.results.length);
+ });
+
+ it('renders course cards with correct links', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
+ });
+
+ const courseCards = screen.getAllByRole('link');
+
+ courseCards.forEach((card, index) => {
+ const course = mockCourseListSearchResponse.results[index];
+ expect(card).toHaveAttribute('href', `/courses/${course.id}/about`);
+ });
+ });
+
+ it('renders course images with correct URLs and alt text', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
+ });
+
+ const courseCards = screen.getAllByRole('link');
+
+ courseCards.forEach((card, index) => {
+ const course = mockCourseListSearchResponse.results[index];
+ const cardContent = within(card);
+
+ const courseImage = cardContent.getByAltText(`${course.data.content.displayName} ${course.data.number}`);
+ expect(courseImage).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}${course.data.imageUrl}`);
+ });
+ });
+
+ it('renders course text content correctly', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
+ });
+
+ const courseCards = screen.getAllByRole('link');
+
+ courseCards.forEach((card, index) => {
+ const course = mockCourseListSearchResponse.results[index];
+ const cardContent = within(card);
+
+ expect(cardContent.getByText(course.data.content.displayName)).toBeInTheDocument();
+ expect(cardContent.getByText(course.data.org)).toBeInTheDocument();
+ expect(cardContent.getByText(course.data.number)).toBeInTheDocument();
+ });
+ });
+
+ it('renders course start dates correctly with advertisedStart priority', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
+ });
+
+ const courseCards = screen.getAllByRole('link');
+
+ courseCards.forEach((card, index) => {
+ const course = mockCourseListSearchResponse.results[index];
+ const cardContent = within(card);
+
+ expect(cardContent.getByText(
+ courseCardMessages.startDate.defaultMessage.replace('{startDate}', course.data.advertisedStart),
+ )).toBeInTheDocument();
+ });
+ });
+
+ it('renders formatted start date when advertisedStart is not available', async () => {
+ const mockResponseWithoutAdvertisedStart = {
+ ...mockCourseListSearchResponse,
+ results: mockCourseListSearchResponse.results.map(course => ({
+ ...course,
+ data: {
+ ...course.data,
+ advertisedStart: undefined,
+ },
+ })),
+ };
+
+ mockCourseListSearch.mockReturnValueOnce({
+ data: mockResponseWithoutAdvertisedStart,
+ isLoading: false,
+ isError: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
+ });
+
+ const courseCards = screen.getAllByRole('link');
+
+ courseCards.forEach((card, index) => {
+ const course = mockResponseWithoutAdvertisedStart.results[index];
+ const cardContent = within(card);
+
+ const expectedDate = new Intl.DateTimeFormat(
+ 'en-US',
+ DATE_FORMAT_OPTIONS,
+ ).format(new Date(course.data.start));
+
+ expect(cardContent.getByText(
+ courseCardMessages.startDate.defaultMessage.replace('{startDate}', expectedDate),
+ )).toBeInTheDocument();
+ });
+ });
+ });
});
diff --git a/src/home/HomePage.tsx b/src/home/HomePage.tsx
index 4e70a865..8e32bf69 100644
--- a/src/home/HomePage.tsx
+++ b/src/home/HomePage.tsx
@@ -1,7 +1,11 @@
import HomeBannerSlot from '../plugin-slots/HomeBannerSlot';
+import HomeCoursesListSlot from '../plugin-slots/HomeCoursesListSlot';
const HomePage = () => (
-
+ <>
+
+
+ >
);
export default HomePage;
diff --git a/src/home/components/courses-list/CoursesList.test.tsx b/src/home/components/courses-list/CoursesList.test.tsx
new file mode 100644
index 00000000..456f4bf5
--- /dev/null
+++ b/src/home/components/courses-list/CoursesList.test.tsx
@@ -0,0 +1,195 @@
+import { getConfig } from '@edx/frontend-platform';
+
+import {
+ render, userEvent, cleanup, within, screen, reactRouter,
+} from '@src/setupTest';
+import { mockCourseListSearchResponse } from '@src/__mocks__';
+import { useCourseListSearch } from '@src/data/course-list-search/hooks';
+import CoursesList from './CoursesList';
+
+import messages from './messages';
+
+jest.mock('@src/data/course-list-search/hooks', () => ({
+ useCourseListSearch: jest.fn(),
+}));
+
+jest.mock('@edx/frontend-platform/react', () => ({
+ ErrorPage: ({ message }: { message: string }) => (
+ {message}
+ ),
+}));
+
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(() => ({
+ INFO_EMAIL: process.env.INFO_EMAIL,
+ HOMEPAGE_COURSE_MAX: process.env.HOMEPAGE_COURSE_MAX,
+ ENABLE_COURSE_SORTING_BY_START_DATE: process.env.ENABLE_COURSE_SORTING_BY_START_DATE,
+ NON_BROWSABLE_COURSES: process.env.NON_BROWSABLE_COURSES,
+ })),
+}));
+
+const mockUseCourseListSearch = useCourseListSearch as jest.Mock;
+
+afterEach(() => {
+ jest.clearAllMocks();
+ cleanup();
+});
+
+describe('', () => {
+ it('shows loading state', () => {
+ mockUseCourseListSearch.mockReturnValue({
+ isLoading: true,
+ isError: false,
+ data: null,
+ });
+
+ render();
+
+ expect(screen.getByTestId('courses-list-loading')).toBeInTheDocument();
+ });
+
+ it('shows correct number of skeleton cards based on max courses config', () => {
+ mockUseCourseListSearch.mockReturnValue({
+ isLoading: true,
+ isError: false,
+ data: null,
+ });
+
+ getConfig.mockReturnValue({
+ HOMEPAGE_COURSE_MAX: 2,
+ });
+
+ render();
+
+ expect(screen.getAllByTestId('course-card')).toHaveLength(2);
+ // Each CourseCard creates 4 skeleton elements (image, header, section, footer)
+ // So 2 cards × 4 skeletons = 8 total skeleton elements
+ expect(document.querySelectorAll('.react-loading-skeleton')).toHaveLength(8);
+ });
+
+ it('shows default number of skeleton cards when max courses not configured', () => {
+ mockUseCourseListSearch.mockReturnValue({
+ isLoading: true,
+ isError: false,
+ data: null,
+ });
+
+ getConfig.mockReturnValue({
+ HOMEPAGE_COURSE_MAX: undefined,
+ });
+
+ render();
+
+ expect(screen.getByTestId('courses-list-loading')).toBeInTheDocument();
+
+ expect(screen.getAllByTestId('course-card')).toHaveLength(9);
+ // Each CourseCard creates 4 skeleton elements (image, header, section, footer)
+ // So 9 cards × 4 skeletons = 36 total skeleton elements
+ expect(document.querySelectorAll('.react-loading-skeleton')).toHaveLength(36);
+ });
+
+ it('shows empty courses state', () => {
+ mockUseCourseListSearch.mockReturnValue({
+ isLoading: false,
+ isError: false,
+ data: {
+ ...mockCourseListSearchResponse,
+ results: [],
+ },
+ });
+
+ render();
+ const infoAlert = screen.getByRole('alert');
+ expect(within(infoAlert).getByText(messages.noCoursesAvailable.defaultMessage)).toBeInTheDocument();
+ expect(within(infoAlert).getByText(messages.noCoursesAvailableMessage.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('displays courses when data is available', () => {
+ mockUseCourseListSearch.mockReturnValue({
+ isLoading: false,
+ isError: false,
+ data: mockCourseListSearchResponse,
+ });
+
+ render();
+ mockCourseListSearchResponse.results.forEach(course => {
+ expect(screen.getByText(course.data.content.displayName)).toBeInTheDocument();
+ });
+ });
+
+ it('shows "View All Courses" button when more courses are available than max', async () => {
+ const mockNavigate = jest.fn();
+ jest.spyOn(reactRouter, 'useNavigate').mockReturnValue(mockNavigate);
+
+ mockUseCourseListSearch.mockReturnValue({
+ isLoading: false,
+ isError: false,
+ data: mockCourseListSearchResponse,
+ });
+
+ getConfig.mockReturnValue({
+ HOMEPAGE_COURSE_MAX: 1,
+ ENABLE_COURSE_SORTING_BY_START_DATE: false,
+ NON_BROWSABLE_COURSES: false,
+ });
+
+ render();
+ const button = screen.getByText(messages.viewAllCoursesButton.defaultMessage);
+
+ expect(button).toBeInTheDocument();
+ await userEvent.click(button);
+ expect(mockNavigate).toHaveBeenCalledWith('/courses');
+ });
+
+ it('does not show "View All Courses" button when courses ≤ max', () => {
+ mockUseCourseListSearch.mockReturnValue({
+ isLoading: false,
+ isError: false,
+ data: mockCourseListSearchResponse,
+ });
+
+ getConfig.mockReturnValue({
+ HOMEPAGE_COURSE_MAX: 3,
+ ENABLE_COURSE_SORTING_BY_START_DATE: false,
+ NON_BROWSABLE_COURSES: false,
+ });
+
+ render();
+ expect(screen.queryByText(messages.viewAllCoursesButton.defaultMessage)).not.toBeInTheDocument();
+ });
+
+ it('shows error state when courses loading fails', () => {
+ mockUseCourseListSearch.mockReturnValue({
+ isLoading: false,
+ isError: true,
+ data: null,
+ });
+
+ getConfig.mockReturnValue({
+ INFO_EMAIL: process.env.INFO_EMAIL,
+ });
+
+ 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('returns null when NON_BROWSABLE_COURSES is enabled', () => {
+ mockUseCourseListSearch.mockReturnValue({
+ isLoading: false,
+ isError: false,
+ data: mockCourseListSearchResponse,
+ });
+
+ getConfig.mockReturnValue({
+ NON_BROWSABLE_COURSES: true,
+ });
+
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/src/home/components/courses-list/CoursesList.tsx b/src/home/components/courses-list/CoursesList.tsx
new file mode 100644
index 00000000..17cfa3c5
--- /dev/null
+++ b/src/home/components/courses-list/CoursesList.tsx
@@ -0,0 +1,110 @@
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Alert, Button, CardGrid, Container,
+} from '@openedx/paragon';
+import { ErrorPage } from '@edx/frontend-platform/react';
+import { getConfig } from '@edx/frontend-platform';
+import { useNavigate } from 'react-router';
+
+import { useCourseListSearch } from '@src/data/course-list-search/hooks';
+import { AlertNotification } from '@src/generic';
+import { DEFAULT_PAGE_INDEX } from '@src/data/course-list-search/constants';
+import HomeCourseCardSlot from '@src/plugin-slots/HomeCourseCardSlot';
+import { LoaderSlot } from '@src/plugin-slots/LoaderSlot';
+import { ROUTES } from '@src/routes';
+import { DEFAULT_COURSES_COUNT } from '@src/home/constants';
+
+import messages from './messages';
+
+const CARD_GRID_LAYOUT = {
+ xs: 12, md: 6, lg: 4, xl: 3,
+};
+
+const CoursesList = () => {
+ const intl = useIntl();
+ const navigate = useNavigate();
+
+ const maxCourses = getConfig().HOMEPAGE_COURSE_MAX || DEFAULT_COURSES_COUNT;
+
+ const {
+ data: courseData,
+ isLoading: isCoursesLoading,
+ isError: isCoursesError,
+ } = useCourseListSearch({
+ pageSize: maxCourses,
+ pageIndex: DEFAULT_PAGE_INDEX,
+ enableCourseSortingByStartDate: getConfig().ENABLE_COURSE_SORTING_BY_START_DATE || false,
+ });
+
+ const handleNavigateToCoursesPage = () => {
+ navigate(ROUTES.COURSES);
+ };
+
+ if (isCoursesLoading) {
+ return (
+
+
+
+ {Array.from({ length: maxCourses }, (_, index) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ if (isCoursesError) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (getConfig().NON_BROWSABLE_COURSES) {
+ return null;
+ }
+
+ return (
+
+ {!courseData?.results?.length ? (
+
+ ) : (
+
+
+ {courseData?.results?.map(course => (
+
+ ))}
+
+ {courseData?.total > maxCourses && (
+
+ )}
+
+ )}
+
+ );
+};
+
+export default CoursesList;
diff --git a/src/home/components/courses-list/messages.ts b/src/home/components/courses-list/messages.ts
new file mode 100644
index 00000000..558a6daf
--- /dev/null
+++ b/src/home/components/courses-list/messages.ts
@@ -0,0 +1,26 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ errorMessage: {
+ id: 'category.catalog.error-page-message',
+ defaultMessage: 'If you experience repeated failures, please email support at {supportEmail}',
+ description: 'Error page message.',
+ },
+ noCoursesAvailable: {
+ id: 'category.catalog.alert.no-courses-available',
+ defaultMessage: 'No courses available',
+ description: 'No courses available alert title.',
+ },
+ noCoursesAvailableMessage: {
+ id: 'category.catalog.alert.no-courses-available-message',
+ defaultMessage: 'There are currently no courses available in the catalog. Please check back later for new offerings.',
+ description: 'No courses available alert message.',
+ },
+ viewAllCoursesButton: {
+ id: 'category.catalog.home-page.view-courses-button',
+ defaultMessage: 'View all courses',
+ description: 'Label for the button that redirect to courses page.',
+ },
+});
+
+export default messages;
diff --git a/src/home/components/home-banner/HomeBanner.test.tsx b/src/home/components/home-banner/HomeBanner.test.tsx
index 2ed6a59b..8258c325 100644
--- a/src/home/components/home-banner/HomeBanner.test.tsx
+++ b/src/home/components/home-banner/HomeBanner.test.tsx
@@ -1,8 +1,6 @@
-import * as reactRouter from 'react-router';
-
import { ROUTES } from '@src/routes';
import {
- render, userEvent, cleanup, screen,
+ render, userEvent, cleanup, screen, reactRouter,
} from '@src/setupTest';
import HomeBanner from './HomeBanner';
diff --git a/src/home/components/index.ts b/src/home/components/index.ts
new file mode 100644
index 00000000..493f9a3f
--- /dev/null
+++ b/src/home/components/index.ts
@@ -0,0 +1,2 @@
+export { default as HomeBanner } from './home-banner/HomeBanner';
+export { default as CoursesList } from './courses-list/CoursesList';
diff --git a/src/home/constants.ts b/src/home/constants.ts
new file mode 100644
index 00000000..5efedd03
--- /dev/null
+++ b/src/home/constants.ts
@@ -0,0 +1 @@
+export const DEFAULT_COURSES_COUNT = 9;
diff --git a/src/plugin-slots/HomeBannerSlot/README.md b/src/plugin-slots/HomeBannerSlot/README.md
index 59fb640b..22872e04 100644
--- a/src/plugin-slots/HomeBannerSlot/README.md
+++ b/src/plugin-slots/HomeBannerSlot/README.md
@@ -1,4 +1,4 @@
-# Home banner slot
+# Home page banner slot
### Slot ID: `org.openedx.frontend.catalog.home_page.banner`
@@ -29,7 +29,7 @@ const config = {
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
- id: 'custom_home_banner_component',
+ id: 'custom_home_page_banner_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
🦶
diff --git a/src/plugin-slots/HomeCourseCardSlot/README.md b/src/plugin-slots/HomeCourseCardSlot/README.md
new file mode 100644
index 00000000..55b435b7
--- /dev/null
+++ b/src/plugin-slots/HomeCourseCardSlot/README.md
@@ -0,0 +1,45 @@
+# Home page course card slot
+
+### Slot ID: `org.openedx.frontend.catalog.home_page.course_card`
+
+## Description
+
+This slot is used to replace/modify/hide the entire Home page course card.
+
+## Examples
+
+### Default content
+
+
+
+### Replaced with custom component
+
+
+
+The following `env.config.tsx` will replace the Home page course card entirely (in this case with a centered `h1` tag)
+
+```tsx
+import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
+
+const config = {
+ pluginSlots: {
+ 'org.openedx.frontend.catalog.home_page.course_card': {
+ keepDefault: false,
+ plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'custom_home_page_course_card_component',
+ type: DIRECT_PLUGIN,
+ RenderWidget: () => (
+ 🦶
+ ),
+ },
+ },
+ ]
+ }
+ },
+}
+
+export default config;
+```
diff --git a/src/plugin-slots/HomeCourseCardSlot/images/screenshot_custom.png b/src/plugin-slots/HomeCourseCardSlot/images/screenshot_custom.png
new file mode 100644
index 00000000..d4ee8767
Binary files /dev/null and b/src/plugin-slots/HomeCourseCardSlot/images/screenshot_custom.png differ
diff --git a/src/plugin-slots/HomeCourseCardSlot/images/screenshot_default.png b/src/plugin-slots/HomeCourseCardSlot/images/screenshot_default.png
new file mode 100644
index 00000000..09f1990a
Binary files /dev/null and b/src/plugin-slots/HomeCourseCardSlot/images/screenshot_default.png differ
diff --git a/src/plugin-slots/HomeCourseCardSlot/index.tsx b/src/plugin-slots/HomeCourseCardSlot/index.tsx
new file mode 100644
index 00000000..e38d922d
--- /dev/null
+++ b/src/plugin-slots/HomeCourseCardSlot/index.tsx
@@ -0,0 +1,20 @@
+import { PluginSlot } from '@openedx/frontend-plugin-framework';
+
+import { CourseCard } from '@src/generic';
+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) => (
+
+
+
+);
+
+export default HomeCourseCardSlot;
diff --git a/src/plugin-slots/HomeCoursesListSlot/README.md b/src/plugin-slots/HomeCoursesListSlot/README.md
new file mode 100644
index 00000000..2ce1f98a
--- /dev/null
+++ b/src/plugin-slots/HomeCoursesListSlot/README.md
@@ -0,0 +1,45 @@
+# Home page courses list slot
+
+### Slot ID: `org.openedx.frontend.catalog.home_page.courses_list`
+
+## Description
+
+This slot is used to replace/modify/hide the entire Home page courses list.
+
+## Examples
+
+### Default content
+
+
+
+### Replaced with custom component
+
+
+
+The following `env.config.tsx` will replace the Home page courses list entirely (in this case with a centered `h1` tag)
+
+```tsx
+import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
+
+const config = {
+ pluginSlots: {
+ 'org.openedx.frontend.catalog.home_page.courses_list': {
+ keepDefault: false,
+ plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'custom_home_page_courses_list_component',
+ type: DIRECT_PLUGIN,
+ RenderWidget: () => (
+ 🦶
+ ),
+ },
+ },
+ ]
+ }
+ },
+}
+
+export default config;
+```
diff --git a/src/plugin-slots/HomeCoursesListSlot/images/screenshot_custom.png b/src/plugin-slots/HomeCoursesListSlot/images/screenshot_custom.png
new file mode 100644
index 00000000..752de441
Binary files /dev/null and b/src/plugin-slots/HomeCoursesListSlot/images/screenshot_custom.png differ
diff --git a/src/plugin-slots/HomeCoursesListSlot/images/screenshot_default.png b/src/plugin-slots/HomeCoursesListSlot/images/screenshot_default.png
new file mode 100644
index 00000000..09f1990a
Binary files /dev/null and b/src/plugin-slots/HomeCoursesListSlot/images/screenshot_default.png differ
diff --git a/src/plugin-slots/HomeCoursesListSlot/index.tsx b/src/plugin-slots/HomeCoursesListSlot/index.tsx
new file mode 100644
index 00000000..6a599053
--- /dev/null
+++ b/src/plugin-slots/HomeCoursesListSlot/index.tsx
@@ -0,0 +1,18 @@
+import { PluginSlot } from '@openedx/frontend-plugin-framework';
+
+import CoursesList from '@src/home/components/courses-list/CoursesList';
+
+// TODO: Resolve the issue with the pluginProps.
+// https://github.com/openedx/frontend-app-catalog/pull/18#pullrequestreview-3212047271
+const HomeCoursesListSlot = () => (
+
+
+
+);
+
+export default HomeCoursesListSlot;
diff --git a/src/plugin-slots/LoaderSlot/README.md b/src/plugin-slots/LoaderSlot/README.md
new file mode 100644
index 00000000..a1bb89d9
--- /dev/null
+++ b/src/plugin-slots/LoaderSlot/README.md
@@ -0,0 +1,48 @@
+# Generic loader slot
+
+### Slot ID: `org.openedx.frontend.catalog.generic.loader`
+
+## Description
+
+This slot is used to replace/modify/hide the entire content of a specified container during a loading state.
+
+## Examples
+
+### Default content
+
+
+
+### Replaced with custom component
+
+
+
+The following `env.config.tsx` will replace the default course cards skeleton on the Home page with a centered spinner component:
+
+```tsx
+import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
+import { Spinner, Container } from '@openedx/paragon';
+
+const config = {
+ pluginSlots: {
+ 'org.openedx.frontend.catalog.generic.loader': {
+ keepDefault: false,
+ plugins: [
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'custom_generic_loader_component',
+ type: DIRECT_PLUGIN,
+ RenderWidget: () => (
+
+
+
+ ),
+ },
+ },
+ ]
+ }
+ },
+}
+
+export default config;
+```
diff --git a/src/plugin-slots/LoaderSlot/images/screenshot_custom.png b/src/plugin-slots/LoaderSlot/images/screenshot_custom.png
new file mode 100644
index 00000000..8e292af2
Binary files /dev/null and b/src/plugin-slots/LoaderSlot/images/screenshot_custom.png differ
diff --git a/src/plugin-slots/LoaderSlot/images/screenshot_default.png b/src/plugin-slots/LoaderSlot/images/screenshot_default.png
new file mode 100644
index 00000000..b2550af4
Binary files /dev/null and b/src/plugin-slots/LoaderSlot/images/screenshot_default.png differ
diff --git a/src/plugin-slots/LoaderSlot/index.tsx b/src/plugin-slots/LoaderSlot/index.tsx
new file mode 100644
index 00000000..970ef492
--- /dev/null
+++ b/src/plugin-slots/LoaderSlot/index.tsx
@@ -0,0 +1,12 @@
+import { PluginSlot } from '@openedx/frontend-plugin-framework';
+
+export const LoaderSlot = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
diff --git a/src/plugin-slots/README.md b/src/plugin-slots/README.md
index 690ab393..25d90dd3 100644
--- a/src/plugin-slots/README.md
+++ b/src/plugin-slots/README.md
@@ -6,3 +6,6 @@
* [`org.openedx.frontend.catalog.home_page.promo_video_button`](./HomePromoVideoButtonSlot/)
* [`org.openedx.frontend.catalog.home_page.promo_video_modal`](./HomePromoVideoModalSlot/)
* [`org.openedx.frontend.catalog.home_page.promo_video_modal_content`](./VideoModalContentSlot/)
+* [`org.openedx.frontend.catalog.home_page.courses_list`](./HomeCoursesListSlot/)
+* [`org.openedx.frontend.catalog.home_page.course_card`](./HomeCourseCardSlot/)
+* [`org.openedx.frontend.catalog.generic.loader`](./LoaderSlot/)
diff --git a/src/setupTest.tsx b/src/setupTest.tsx
index 5b98470a..9a55c02f 100644
--- a/src/setupTest.tsx
+++ b/src/setupTest.tsx
@@ -12,6 +12,7 @@ import userEvent from '@testing-library/user-event';
import PropTypes from 'prop-types';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import * as reactRouter from 'react-router';
function render(ui) {
const queryClient = new QueryClient({
@@ -49,4 +50,5 @@ export {
screen,
userEvent,
cleanup,
+ reactRouter,
};