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 + +![Home page course card with default content](./images/screenshot_default.png) + +### Replaced with custom component + +![🦶 in Home page course card slot](./images/screenshot_custom.png) + +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 + +![Home page courses list with default content](./images/screenshot_default.png) + +### Replaced with custom component + +![🦶 in Home page courses list slot](./images/screenshot_custom.png) + +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 + +![Default Home Page courses list skeleton loader](./images/screenshot_default.png) + +### Replaced with custom component + +![Custom Home Page courses list spinner loader](./images/screenshot_custom.png) + +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, };